├── README.md ├── TaipeiSansTCBeta-Regular.ttf ├── images ├── img_main_screen.png ├── img_pic_selector.png ├── img_roi_selector.png └── img_sfr_result.png ├── main.py ├── sfr.kv ├── sfr.py └── sfr_calculator.spec /README.md: -------------------------------------------------------------------------------- 1 | # Python SFR 2 | 3 | This is a simple project written in Python to allow user using python to get the SFR reading from a captured image of the ISO12233(2000) chart (or any SFR test chart) 4 | 5 | ![alt text](https://github.com/jacktseng831/Python_SFR/blob/master/images/img_main_screen.png "Main Screen") 6 | ![alt text](https://github.com/jacktseng831/Python_SFR/blob/master/images/img_pic_selector.png "File Selector") 7 | ![alt text](https://github.com/jacktseng831/Python_SFR/blob/master/images/img_roi_selector.png "ROI Selector") 8 | ![alt text](https://github.com/jacktseng831/Python_SFR/blob/master/images/img_sfr_result.png "SFR Result") 9 | 10 | ## Required packages 11 | 12 | - [Python](https://www.python.org/downloads/) 13 | - [kivy](https://kivy.org/#download) 14 | - [SciPy](https://www.scipy.org/install.html) 15 | - [Pillow](https://pypi.org/project/Pillow/) 16 | 17 | ## How to use 18 | 19 | ```bash 20 | cd Pytohn_SFR 21 | python main.py 22 | ``` 23 | -------------------------------------------------------------------------------- /TaipeiSansTCBeta-Regular.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jacktseng831/Python_SFR/3b56352c4af7608793c60940120de38fb99da086/TaipeiSansTCBeta-Regular.ttf -------------------------------------------------------------------------------- /images/img_main_screen.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jacktseng831/Python_SFR/3b56352c4af7608793c60940120de38fb99da086/images/img_main_screen.png -------------------------------------------------------------------------------- /images/img_pic_selector.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jacktseng831/Python_SFR/3b56352c4af7608793c60940120de38fb99da086/images/img_pic_selector.png -------------------------------------------------------------------------------- /images/img_roi_selector.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jacktseng831/Python_SFR/3b56352c4af7608793c60940120de38fb99da086/images/img_roi_selector.png -------------------------------------------------------------------------------- /images/img_sfr_result.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jacktseng831/Python_SFR/3b56352c4af7608793c60940120de38fb99da086/images/img_sfr_result.png -------------------------------------------------------------------------------- /main.py: -------------------------------------------------------------------------------- 1 | __version__ = '1.0' 2 | 3 | # workaround for pyinstaller packing, disabled by default 4 | #import numpy.random.common 5 | #import numpy.random.bounded_integers 6 | #import numpy.random.entropy 7 | #import win32timezone 8 | 9 | from functools import partial 10 | from itertools import chain 11 | import math 12 | import os 13 | import pathlib 14 | from random import random 15 | 16 | # disable multi-touch emulation 17 | from kivy.config import Config 18 | Config.set('input', 'mouse', 'mouse,multitouch_on_demand') 19 | 20 | import kivy 21 | kivy.require('1.8.0') 22 | 23 | from kivy.app import App 24 | from kivy.clock import Clock 25 | from kivy.core.text import Label as CoreLabel 26 | from kivy.core.window import Window 27 | from kivy.graphics import Color, Point, Rectangle, Line 28 | from kivy.graphics.texture import Texture 29 | from kivy.properties import ObjectProperty, StringProperty, NumericProperty, DictProperty 30 | from kivy.uix.bubble import Bubble 31 | from kivy.uix.floatlayout import FloatLayout 32 | from kivy.uix.label import Label 33 | from kivy.uix.popup import Popup 34 | from kivy.uix.scatter import Scatter 35 | from kivy.utils import platform 36 | 37 | 38 | import numpy 39 | 40 | from sfr import SFR 41 | 42 | class Gradient(object): 43 | 44 | @staticmethod 45 | def horizontal(*args): 46 | texture = Texture.create(size=(len(args), 1), colorfmt='rgba') 47 | buf = bytes([ int(v * 255) for v in chain(*args) ]) # flattens 48 | 49 | texture.blit_buffer(buf, colorfmt='rgba', bufferfmt='ubyte') 50 | return texture 51 | 52 | @staticmethod 53 | def vertical(*args): 54 | texture = Texture.create(size=(1, len(args)), colorfmt='rgba') 55 | buf = bytes([ int(v * 255) for v in chain(*args) ]) # flattens 56 | 57 | texture.blit_buffer(buf, colorfmt='rgba', bufferfmt='ubyte') 58 | return texture 59 | 60 | class LoadDialog(FloatLayout): 61 | load = ObjectProperty(None) 62 | cancel = ObjectProperty(None) 63 | update = ObjectProperty(None) 64 | 65 | def __init__(self, **kwargs): 66 | super(LoadDialog, self).__init__(**kwargs) 67 | # Special process for Windows 68 | if platform == 'win': 69 | import win32api 70 | self.ids.spinner.opacity = 1 71 | self.ids.spinner.size_hint_max_y = 30 72 | self.ids.spinner.values = win32api.GetLogicalDriveStrings().split('\000')[:-1] 73 | self.ids.spinner.values.append(str(pathlib.Path.home())) 74 | self.ids.spinner.text = self.ids.spinner.values[-1] 75 | def change_drive(spinner, text): 76 | self.ids.filechooser.path = text 77 | self.ids.spinner.bind(text=change_drive) 78 | 79 | class MessageBox(Bubble): 80 | load = ObjectProperty(None) 81 | cancel = ObjectProperty(None) 82 | message = StringProperty(None) 83 | 84 | class Toast(Bubble): 85 | message = StringProperty(None) 86 | 87 | class RoiSelector(Scatter): 88 | load = ObjectProperty(None) 89 | cancel = ObjectProperty(None) 90 | image = ObjectProperty(None) 91 | source = StringProperty(None) 92 | 93 | crossline = None 94 | crossline_label = None 95 | crossline_label_region = None 96 | roi = None 97 | img_offset = (0,0) 98 | last_image_width = None 99 | zoom_anchor = (0,0) 100 | messagebox = None 101 | touched = False 102 | mouse_pos_binded = False 103 | 104 | def __init__(self, **kwargs): 105 | super(RoiSelector, self).__init__(**kwargs) 106 | self.enable_mouse_move_event(True) 107 | self.bind(size=self.show) 108 | 109 | def enable_mouse_move_event(self, enabled): 110 | if enabled == True and self.mouse_pos_binded == False: 111 | Window.bind(mouse_pos=self.on_mouse_move) 112 | self.mouse_pos_binded = True 113 | elif self.mouse_pos_binded == True: 114 | Window.unbind(mouse_pos=self.on_mouse_move) 115 | self.mouse_pos_binded = False 116 | 117 | def on_touch_down(self, touch): 118 | self.touched = True 119 | if touch.is_mouse_scrolling: 120 | old_scale = self.scale 121 | if self.scale == 1: 122 | self.zoom_anchor = touch.pos 123 | if touch.button == 'scrolldown' and self.scale < 4: 124 | self.scale *= 1.25 125 | elif touch.button == 'scrollup' and self.scale > 1: 126 | self.scale *= 0.8 127 | 128 | # translate the points from image to self before moving the image 129 | self.crossline[0].pos = self.pos_image_to_self(self.crossline[0].pos) 130 | self.crossline[1].pos = self.pos_image_to_self(self.crossline[1].pos) 131 | self.roi.pos = self.pos_image_to_self(self.roi.pos) 132 | # new point = (image center - zoom anchor) * (scale - 1) / scale + offset 133 | self.image.pos = numpy.add( numpy.multiply( numpy.subtract( numpy.divide(self.size,2), self.zoom_anchor), (self.scale-1)/(self.scale)), self.img_offset).tolist() 134 | # translate the points from self to image after the image moved 135 | self.crossline[0].pos = self.pos_self_to_image(self.crossline[0].pos) 136 | self.crossline[1].pos = self.pos_self_to_image(self.crossline[1].pos) 137 | self.roi.pos = self.pos_self_to_image(self.roi.pos) 138 | self.update_corssline_label() 139 | if self.messagebox is not None: 140 | self.update_messagebox() 141 | elif touch.button == 'left': 142 | self.reset_roi() 143 | touch.grab(self) 144 | self.roi.pos = self.pos_parent_to_self(touch.pos) 145 | elif touch.button == 'right' and touch.grab_current is not self: 146 | self.enable_mouse_move_event(False) 147 | self.reset_roi() 148 | self.cancel() 149 | 150 | def on_touch_move(self, touch): 151 | if touch.grab_current is self: 152 | self.roi.size = numpy.subtract(self.pos_parent_to_self(touch.pos), self.roi.pos).tolist() 153 | self.update_corssline_label() 154 | 155 | def on_touch_up(self, touch): 156 | if touch.is_mouse_scrolling: 157 | print(touch.button) 158 | elif touch.grab_current is self: 159 | touch.ungrab(self) 160 | if self.crossline_label.color == [1,1,1,1]: 161 | self.messagebox = MessageBox(message='Would you like to use this region?', size=(280, 100), cancel=self.reset_roi, load=self.load_roi_image) 162 | self.parent.add_widget(self.messagebox) 163 | self.update_messagebox() 164 | else: 165 | self.roi.pos = self.pos_parent_to_self(touch.pos) 166 | self.roi.size = (0, 0) 167 | self.update_corssline_label() 168 | self.touched = False 169 | 170 | def on_mouse_move(self, instance, mouse_pos): 171 | # do not proceed if I'm not displayed <=> If have no parent 172 | if not self.get_root_window(): 173 | print("Return") 174 | return 175 | 176 | pos = self.pos_parent_to_self(mouse_pos) 177 | 178 | if self.crossline is None: 179 | with self.image.canvas: 180 | # draw the crossline 181 | Color(1, 0, 0, 0.5) 182 | self.crossline = [Rectangle(pos=(pos[0], self.image.pos[1]), size=(1, self.height)), Rectangle(pos=(self.image.pos[0], pos[1]), size=(self.width, 1))] 183 | # draw the ROI 184 | Color(1, 0, 0, 0.5) 185 | self.roi = Rectangle(pos=(0, 0), size=(0, 0)) 186 | self.crossline_label = Label(size_hint=(None, None), bold=True, color=(1,1,1,1)) 187 | with self.crossline_label.canvas.before: 188 | # draw the label background 189 | Color(0.2, 0.2, 0.2, 0.5) 190 | self.crossline_label_region = Rectangle(pos=self.crossline_label.pos, size=self.crossline_label.size) 191 | self.image.add_widget(self.crossline_label) 192 | elif self.messagebox is None: 193 | self.crossline[0].pos = (pos[0], self.image.pos[1]) 194 | self.crossline[1].pos = (self.image.pos[0], pos[1]) 195 | 196 | # don't update the label when there is a touch event since we've already done it in the touch functions 197 | if not self.touched: 198 | self.update_corssline_label() 199 | 200 | def show(self, *args): 201 | old_img_width = self.image.width 202 | old_img_offset = self.img_offset 203 | if self.height*self.image.image_ratio < self.width: 204 | self.image.size = (self.height*self.image.image_ratio, self.height) 205 | else: 206 | self.image.size = (self.width, self.width/self.image.image_ratio) 207 | self.pos = (-self.width*(self.scale-1)/2, -self.height*(self.scale-1)/2) 208 | if self.image.width is None: 209 | ratio = 1 210 | else: 211 | ratio = self.image.width/old_img_width 212 | self.img_offset = ((self.width-self.image.width)/2,(self.height-self.image.height)/2) 213 | # new point = (old point - old offset) * ratio + new offset 214 | self.image.pos = numpy.add( numpy.multiply( numpy.subtract(self.image.pos, old_img_offset), ratio), self.img_offset).tolist() 215 | self.zoom_anchor = numpy.add( numpy.multiply( numpy.subtract(self.zoom_anchor, old_img_offset), ratio), self.img_offset).tolist() 216 | if self.crossline is not None: 217 | self.crossline[0].pos = ((self.crossline[0].pos[0] - old_img_offset[0]) * ratio + self.img_offset[0], self.image.pos[1]) 218 | self.crossline[1].pos = (self.image.pos[0], (self.crossline[1].pos[1] - old_img_offset[1]) * ratio + self.img_offset[1]) 219 | self.crossline[0].size = (1, self.height * self.scale) 220 | self.crossline[1].size = (self.width * self.scale, 1) 221 | self.roi.pos = numpy.add( numpy.multiply( numpy.subtract(self.roi.pos, old_img_offset), ratio), self.img_offset).tolist() 222 | self.roi.size = numpy.multiply( self.roi.size, ratio).tolist() 223 | self.update_corssline_label() 224 | if self.messagebox is not None: 225 | def update_messagebox_after_resize(*t): 226 | self.update_messagebox() 227 | Clock.schedule_once(update_messagebox_after_resize) 228 | last_image_width = self.image.width 229 | return 230 | 231 | def update_messagebox(self): 232 | pos = self.pos_self_to_parent(self.roi.pos) 233 | size = numpy.multiply( self.roi.size, self.scale).tolist() 234 | # find the bottom left point 235 | start = (min(pos[0], pos[0]+size[0]), min(pos[1], pos[1]+size[1])) 236 | points = [(pos[0], pos[1])] 237 | points.append((pos[0], pos[1]+size[1])) 238 | points.append((pos[0]+size[0], pos[1]+size[1])) 239 | points.append((pos[0]+size[0], pos[1])) 240 | for p in points: 241 | ng = 0 242 | # test if the point is visible to user 243 | if p[0] > self.width or p[0] < 0 or p[1] > self.height or p[1] < 0: 244 | continue 245 | # test if messagebox will be out of window in vertical direction 246 | if p[1] == start[1] and p[1]-self.messagebox.height > 0: 247 | y_dir = 'top' 248 | elif p[1]+self.messagebox.height < self.height: 249 | y_dir = 'bottom' 250 | elif p[1]-self.messagebox.height > 0: 251 | y_dir = 'top' 252 | else: 253 | continue 254 | if (p[1] == start[1] and y_dir == 'bottom') or (p[1] != start[1] and y_dir != 'bottom'): 255 | ng += 1 256 | # test if messagebox will be out of window in horizontal direction 257 | if ((ng != 0 and p[0] == start[0]) or (ng == 0 and p[0] != start[0])) and p[0]-self.messagebox.width > 0: 258 | x_dir = 'right' 259 | elif p[0]+self.messagebox.width < self.width: 260 | x_dir = 'left' 261 | elif p[0]-self.messagebox.width > 0: 262 | x_dir = 'right' 263 | else: 264 | continue 265 | if (p[0] == start[0] and x_dir == 'left') or (p[0] != start[0] and x_dir != 'left'): 266 | ng += 2 267 | # setup messagebox 268 | self.messagebox.x = p[0] if x_dir == 'left' else p[0] - self.messagebox.width 269 | self.messagebox.y = p[1] if y_dir == 'bottom' else p[1] - self.messagebox.height 270 | if ng/2 < 1: 271 | self.messagebox.arrow_pos = x_dir+'_'+y_dir 272 | else: 273 | self.messagebox.arrow_pos = y_dir+'_'+x_dir 274 | if ng == 3 or ng == 0: 275 | continue 276 | else: 277 | break 278 | 279 | def reset_roi(self): 280 | # TODO: ROI need to be cleared after user back from the SFR viewer. Or, user will see the previous ROI shifted when he pressed the left button. 281 | if self.messagebox is not None: 282 | self.parent.remove_widget(self.messagebox) 283 | self.messagebox = None 284 | self.roi.size = (0, 0) 285 | self.update_corssline_label() 286 | 287 | def pos_parent_to_self(self, point): 288 | # self point = (parent point + (scale - 1) * self center) / scale 289 | return numpy.divide( numpy.add( point, numpy.multiply( numpy.divide( self.size, 2), self.scale-1)), self.scale).tolist() 290 | 291 | def pos_self_to_parent(self, point): 292 | # parent point = self point * scale + (1 - sclae) * self center) 293 | return numpy.add( numpy.multiply( point, self.scale), numpy.multiply( numpy.divide( self.size, 2), 1-self.scale)).tolist() 294 | 295 | def pos_self_to_image(self, point): 296 | # image point = self point + image position 297 | return numpy.add( point, self.image.pos).tolist() 298 | 299 | def pos_image_to_self(self, point): 300 | # self point = image point - image position 301 | return numpy.subtract( point, self.image.pos).tolist() 302 | 303 | def get_roi_ltrb(self): 304 | pos = self.pos_image_to_self(self.roi.pos) 305 | # find the top left and the bottom right points 306 | start = (min(pos[0], pos[0]+self.roi.size[0]), max(pos[1], pos[1]+self.roi.size[1])) 307 | end = (max(pos[0], pos[0]+self.roi.size[0]), min(pos[1], pos[1]+self.roi.size[1])) 308 | roi_start = numpy.multiply( numpy.divide( start, self.image.size), self.image._coreimage.size).tolist() 309 | roi_end = numpy.multiply( numpy.divide( end, self.image.size), self.image._coreimage.size).tolist() 310 | # NOTE: (0,0) in python is at the bottom left, but it is at the top left in the image 311 | roi_start = (round(roi_start[0]), round(self.image._coreimage.size[1]-roi_start[1])) 312 | roi_end = (round(roi_end[0]), round(self.image._coreimage.size[1]-roi_end[1])) 313 | return roi_start+roi_end 314 | 315 | def update_corssline_label(self): 316 | ltrb = self.get_roi_ltrb() 317 | w = ltrb[2]-ltrb[0] 318 | h = ltrb[3]-ltrb[1] 319 | if w == 0 or h == 0: 320 | self.crossline_label.color = (0.75, 0.75, 0.75, 1) 321 | self.crossline_label.text = "No ROI" 322 | elif ltrb[0] < 0 or ltrb[1] < 0 or ltrb[2] > self.image._coreimage.size[0] or ltrb[3] > self.image._coreimage.size[1]: 323 | self.crossline_label.color = (1, 0, 0, 1) 324 | self.crossline_label.text = "Invalid ROI" 325 | else: 326 | self.crossline_label.color = (1, 1, 1, 1) 327 | self.crossline_label.text = "Pos: ({0:d}, {1:d})\nSize: {2:d} x {3:d}".format(ltrb[0],ltrb[1],w,h) 328 | self.crossline_label.texture_update() 329 | self.crossline_label.pos = (self.crossline[0].pos[0]+1, self.crossline[1].pos[1]+1) 330 | self.crossline_label.size = (self.crossline_label.texture_size[0] + 20, self.crossline_label.texture_size[1] + 20) 331 | self.crossline_label_region.pos = self.crossline_label.pos 332 | self.crossline_label_region.size = self.crossline_label.size 333 | 334 | def load_roi_image(self): 335 | if self.messagebox is not None: 336 | self.parent.remove_widget(self.messagebox) 337 | self.messagebox = None 338 | 339 | # pass back to parent 340 | self.load(self.image._coreimage.filename, self.get_roi_ltrb()) 341 | 342 | class SfrViewer(FloatLayout): 343 | cancel = ObjectProperty(None) 344 | sfr_dict = DictProperty({}) 345 | export_prefix = StringProperty('') 346 | 347 | crossline = None 348 | crossline_label = None 349 | 350 | chart_region = None 351 | x_series = [] 352 | y_series = {} 353 | 354 | data_pt = [0, 0] 355 | 356 | channel = None 357 | colors = {'L':[[0,0,0,1], [0.3,0.3,0.3,1]], 358 | 'R':[[0.9,0,0,1], [0.7,0,0,1]], 359 | 'G':[[0,0.6,0,1], [0,0.4,0,1]], 360 | 'B':[[0,0,1,1], [0,0,0.8,1]], 361 | 'Line':[[0,0,0,1], [0.7,0.7,0.7,1], [0.5,0.5,0.5,1]], 362 | 'Background':[[0.95,0.95,0.95,1], [1,1,1,1]]} 363 | 364 | charts_exported = False 365 | widget_initiated = False 366 | 367 | def __init__(self, **kwargs): 368 | Window.bind(mouse_pos=self.on_mouse_move) 369 | super(SfrViewer, self).__init__(**kwargs) 370 | self.bind(pos=self.show, size=self.show) 371 | 372 | def on_mouse_move(self, instance, mouse_pos): 373 | # do not proceed if I'm not displayed <=> If have no parent 374 | if not self.get_root_window(): 375 | print("Return") 376 | return 377 | 378 | # do not proceed if it's not in the region 379 | if mouse_pos[0] < self.chart_region.pos[0] or mouse_pos[1] < self.chart_region.pos[1] or mouse_pos[0] > self.chart_region.pos[0]+self.chart_region.size[0] or mouse_pos[1] > self.chart_region.pos[1]+self.chart_region.size[1]: 380 | return 381 | 382 | self.display_selected_data(mouse_pos) 383 | 384 | def display_selected_data(self, pos): 385 | data_pt = list(self.chart_region.pos) 386 | # find the closest data point 387 | for x in self.x_series: 388 | if abs(x-pos[0]) < abs(data_pt[0]-pos[0]): 389 | data_pt[0] = x 390 | idx = self.x_series.index(data_pt[0]) 391 | data_pt[1] = self.y_series[self.channel][idx] 392 | 393 | cy_pxl_series = self.sfr_dict['Cy/Pxl'] 394 | lw_ph_series = self.sfr_dict['LW/PH'] 395 | mtf_series = self.sfr_dict['Channels'][self.channel]['MTF'] 396 | mtf_corr_series = self.sfr_dict['Channels'][self.channel]['Corrected MTF'] 397 | 398 | # use interpolation to get the value 399 | if (idx == 0 and pos[0] < data_pt[0]) or (idx == len(self.x_series)-1 and pos[0] > data_pt[0]): 400 | cy_pxl = cy_pxl_series[idx] 401 | lw_ph = lw_ph_series[idx] 402 | mtf = mtf_series[idx] 403 | mtf_corr = mtf_corr_series[idx] 404 | else: 405 | data_pt[0] = pos[0] 406 | shift = 1 if pos[0] > data_pt[0] else -1 407 | slope = (pos[0]-data_pt[0])/(self.x_series[idx+shift]-data_pt[0]) 408 | cy_pxl = cy_pxl_series[idx] + slope * (cy_pxl_series[idx+shift]-cy_pxl_series[idx]) 409 | lw_ph = lw_ph_series[idx] + slope * (lw_ph_series[idx+shift]-lw_ph_series[idx]) 410 | mtf = mtf_series[idx] + slope * (mtf_series[idx+shift]-mtf_series[idx]) 411 | mtf_corr = mtf_corr_series[idx] + slope * (mtf_corr_series[idx+shift]-mtf_corr_series[idx]) 412 | 413 | # get the real data point position 414 | data_pt[1] = self.chart_region.pos[1] + (mtf/max(mtf_series)) * (max(self.y_series[self.channel])-self.chart_region.pos[1]) 415 | 416 | if self.crossline is None: 417 | with self.canvas: 418 | # draw the crossline 419 | Color(rgba=self.colors['Line'][2]) 420 | self.crossline = Line(points=[], width=1) 421 | self.crossline_label = Label(size_hint=(None, None), font_size=18, halign='right', color=self.colors[self.channel][1]) 422 | self.add_widget(self.crossline_label) 423 | 424 | self.crossline.points = [data_pt[0], self.chart_region.pos[1], data_pt[0], data_pt[1], self.chart_region.pos[0], data_pt[1]] 425 | self.crossline_label.text = 'At {0:0.3f} Cy/Pxl = {1:0.0f} LW/PH:\nMTF = {2:0.3f} \nMTF(corr) = {3:0.3f} '.format(cy_pxl, lw_ph, mtf, mtf_corr) 426 | self.crossline_label.texture_update() 427 | self.crossline_label.pos = (self.chart_region.pos[0]+self.chart_region.size[0]-self.crossline_label.texture_size[0]-10, self.chart_region.pos[1]+self.chart_region.size[1]-self.crossline_label.texture_size[1]*3.5) 428 | self.crossline_label.size = self.crossline_label.texture_size 429 | 430 | self.data_pt = data_pt 431 | 432 | def on_touch_up(self, touch): 433 | if touch.button == 'right': 434 | self.cancel() 435 | elif touch.button == 'scrolldown': 436 | self.switch_to_next_channel(reversed=True) 437 | else: 438 | self.switch_to_next_channel() 439 | 440 | def switch_to_next_channel(self, reversed=False): 441 | channels = list(self.sfr_dict['Channels'].keys()) 442 | idx = channels.index(self.channel) 443 | if reversed: 444 | self.channel = channels[idx-1] if idx-1 >= 0 else channels[len(channels)-1] 445 | else: 446 | self.channel = channels[idx+1] if idx+1 < len(channels) else channels[0] 447 | self.show() 448 | 449 | def show(self, *args): 450 | label = CoreLabel(text="0.0") 451 | # force refresh to compute things and generate the texture 452 | label.refresh() 453 | chart_pos = numpy.add( self.pos, numpy.multiply( numpy.ceil( numpy.divide( label.texture.size, 25)), 25)) 454 | chart_size = numpy.subtract( self.size, numpy.multiply( numpy.ceil( numpy.divide( label.texture.size, 25)), 50)).tolist() 455 | if self.crossline is not None: 456 | new_data_pt = numpy.add( numpy.multiply( numpy.subtract( self.data_pt, self.chart_region.pos), numpy.divide( chart_size, self.chart_region.size)), chart_pos) 457 | self.chart_region = Rectangle(pos=chart_pos, size=chart_size) 458 | mtf_interval = 0.2 459 | lw_ph_interval = 500 460 | max_mtf = 1.0 461 | for c in self.sfr_dict['Channels'].keys(): 462 | max_mtf = max(max_mtf, max(self.sfr_dict['Channels'][c]['MTF']), max(self.sfr_dict['Channels'][c]['Corrected MTF'])) 463 | if self.channel == None: 464 | self.channel = c 465 | max_mtf = round(math.ceil(max_mtf / mtf_interval) * mtf_interval, 1) 466 | max_lw_ph = max(self.sfr_dict['LW/PH']) 467 | # re-calculate the lw/ph interval 468 | ratio = chart_size[0]/chart_size[1] 469 | for i in [100, 200, 500, 1000, 2000, 5000]: 470 | if abs(mtf_interval/max_mtf - ratio/math.ceil(max_lw_ph/i)) < abs(mtf_interval/max_mtf - ratio/math.ceil(max_lw_ph/lw_ph_interval)): 471 | lw_ph_interval = i 472 | max_lw_ph = round(math.ceil(max_lw_ph / lw_ph_interval) * lw_ph_interval, -2) 473 | 474 | shapes = {} 475 | self.x_series = pt_x_series = numpy.add( numpy.multiply( self.sfr_dict['LW/PH'], chart_size[0]/max_lw_ph), chart_pos[0]).tolist() 476 | for c in self.sfr_dict['Channels'].keys(): 477 | mtf_shape = [] 478 | mtf_corr_shape = [] 479 | self.y_series[c] = pt_y_series = numpy.add( numpy.multiply( self.sfr_dict['Channels'][c]['MTF'], chart_size[1]/max_mtf), chart_pos[1]).tolist() 480 | self.y_series['{0:s}_corr'.format(c)] = pt_y_corr_series = numpy.add( numpy.multiply( self.sfr_dict['Channels'][c]['Corrected MTF'], chart_size[1]/max_mtf), chart_pos[1]).tolist() 481 | for i in range(len(pt_x_series)): 482 | mtf_shape.append(pt_x_series[i]) 483 | mtf_shape.append(pt_y_series[i]) 484 | mtf_corr_shape.append(pt_x_series[i]) 485 | mtf_corr_shape.append(pt_y_corr_series[i]) 486 | 487 | shapes[c] = [mtf_shape, mtf_corr_shape] 488 | 489 | self.canvas.before.clear() 490 | 491 | with self.canvas.before: 492 | Color(1,1,1,1) 493 | Rectangle(texture=Gradient.vertical(self.colors['Background'][0],self.colors['Background'][1]), pos=self.pos, size=self.size) 494 | Color(rgba=self.colors['Line'][0]) 495 | Line(points=[chart_pos[0], chart_pos[1]+chart_size[1], chart_pos[0], chart_pos[1], chart_pos[0]+chart_size[0], chart_pos[1]], width=1) 496 | for step in numpy.linspace(0, max_mtf, round(max_mtf/mtf_interval)+1): 497 | if step != 0: 498 | Color(rgba=self.colors['Line'][1]) 499 | Line(points=[chart_pos[0], chart_pos[1]+step/max_mtf*chart_size[1], chart_pos[0]+chart_size[0], chart_pos[1]+step/max_mtf*chart_size[1]], width=1) 500 | Color(rgba=self.colors['Line'][0]) 501 | label = CoreLabel(text='{0:0.1f}'.format(step), color=(1, 1, 1, 1)) 502 | label.refresh() 503 | Rectangle(texture=label.texture, pos=(self.x, chart_pos[1]+step/max_mtf*chart_size[1]-label.texture.size[1]/2), size=label.texture.size) 504 | for step in numpy.linspace(0, max_lw_ph, round(max_lw_ph/lw_ph_interval)+1): 505 | if step != 0: 506 | Color(rgba=self.colors['Line'][1]) 507 | Line(points=[chart_pos[0]+step/max_lw_ph*chart_size[0], chart_pos[1], chart_pos[0]+step/max_lw_ph*chart_size[0], chart_pos[1]+chart_size[1]], width=1) 508 | Color(rgba=self.colors['Line'][0]) 509 | label = CoreLabel(text='{0:0.0f}'.format(step), color=(1, 1, 1, 1)) 510 | label.refresh() 511 | Rectangle(texture=label.texture, pos=(chart_pos[0]+step/max_lw_ph*chart_size[0]-label.texture.size[0]/2, self.y), size=label.texture.size) 512 | 513 | Color(rgba=self.colors[self.channel][0]) 514 | Line(points=shapes[self.channel][0], width=2) 515 | label = CoreLabel(text='''[{8:s} Channel] MTF50 = {0:0.3f} Cy/pxl = {1:0.0f} LW/PH 516 | MTF50P = {4:0.3f} Cy/pxl = {5:0.0f} LW/PH 517 | 518 | MTF50(corr) = {2:0.3f} Cy/pxl = {3:0.0f} LW/PH 519 | {6:s} = {7:0.1f} %'''.format(self.sfr_dict['Channels'][self.channel]['MTF50'], 520 | self.sfr_dict['Channels'][self.channel]['MTF50']*self.sfr_dict['Size'][0 if self.sfr_dict['Orientation'] == 'Horizontal' else 1]*2, 521 | self.sfr_dict['Channels'][self.channel]['Corrected MTF50'], 522 | self.sfr_dict['Channels'][self.channel]['Corrected MTF50']*self.sfr_dict['Size'][0 if self.sfr_dict['Orientation'] == 'Horizontal' else 1]*2, 523 | self.sfr_dict['Channels'][self.channel]['MTF50P'], 524 | self.sfr_dict['Channels'][self.channel]['MTF50P']*self.sfr_dict['Size'][0 if self.sfr_dict['Orientation'] == 'Horizontal' else 1]*2, 525 | 'Undersharpening' if self.sfr_dict['Channels'][self.channel]['Sharpening'] < 0 else 'Oversharpening', 526 | abs(self.sfr_dict['Channels'][self.channel]['Sharpening'])*100, 527 | self.channel), 528 | font_size=18, halign='right', color=(1, 1, 1, 1)) 529 | label.refresh() 530 | Rectangle(texture=label.texture, pos=(chart_pos[0]+chart_size[0]-label.texture.size[0]-10,chart_pos[1]+chart_size[1]-label.texture.size[1]-10), size=label.texture.size) 531 | 532 | Color(rgba=self.colors[self.channel][1]) 533 | Line(points=shapes[self.channel][1], width=1) 534 | 535 | if self.crossline is not None: 536 | self.crossline_label.pos = (self.chart_region.pos[0]+self.chart_region.size[0]-self.crossline_label.texture_size[0]-10, self.chart_region.pos[1]+self.chart_region.size[1]-self.crossline_label.texture_size[1]*3.5) 537 | self.crossline_label.color = self.colors[self.channel][1] 538 | self.display_selected_data(new_data_pt) 539 | 540 | if not self.widget_initiated: 541 | self.widget_initiated = True 542 | elif not self.charts_exported: 543 | self.export_to_png('{0:s}_{1:s}_MTF.png'.format(self.export_prefix,self.channel)) 544 | channels = list(self.sfr_dict['Channels'].keys()) 545 | if channels.index(self.channel) == len(channels)-1: 546 | self.charts_exported = True 547 | self.switch_to_next_channel() 548 | 549 | class Workspace(FloatLayout): 550 | roi_selector = None 551 | 552 | def on_touch_down(self, touch): 553 | if self.roi_selector is None: 554 | touch.grab(self) 555 | else: 556 | return super(Workspace, self).on_touch_down(touch) 557 | 558 | def on_touch_up(self, touch): 559 | if touch.grab_current is self: 560 | touch.ungrab(self) 561 | if touch.button == 'left': 562 | self.show_load_dialog() 563 | else: 564 | return super(Workspace, self).on_touch_up(touch) 565 | 566 | def show_load_dialog(self): 567 | self._popup = Popup(title='Load image file', title_size='24sp', size_hint=(0.9, 0.9)) 568 | self._popup.content = LoadDialog(load=self.load_image_to_selector, cancel=self._popup.dismiss, update=self.update_dialog_image) 569 | self._popup.open() 570 | 571 | def load_image_to_selector(self, file_path): 572 | if os.path.isfile(file_path): 573 | self.roi_selector = RoiSelector(source=file_path, load=self.get_sfr_from_roi, cancel=self.close_roi_selector) 574 | self._popup.dismiss() 575 | self.add_widget(self.roi_selector) 576 | 577 | def update_dialog_image(self, file_path, image): 578 | self._popup.title=file_path 579 | if os.path.isfile(file_path): 580 | image.source = file_path 581 | image.color = [1,1,1,1] 582 | 583 | def get_sfr_from_roi(self, image_path, image_roi): 584 | toast = Toast(message='Calculating MTF ...', size=(140, 50), pos_hint={'center_x': .5, 'center_y': .5}) 585 | self.add_widget(toast) 586 | def calculate_sfr(*t): 587 | os.chdir(os.path.dirname(image_path)) 588 | if not os.path.isdir('Results'): 589 | os.mkdir('Results') 590 | os.chdir('Results') 591 | outputs = SFR(image_path, image_roi, oversampling_rate=4).calculate(export_csv='{0:s}_summary.csv'.format(os.path.basename(os.path.splitext(image_path)[0]))) 592 | self.remove_widget(toast) 593 | self.roi_selector.image.export_to_png('{0:s}_MTF_ROI.png'.format(os.path.basename(os.path.splitext(image_path)[0]))) 594 | self._popup = Popup(title='Results of {0:s}'.format(os.path.basename(image_path)), title_size='24', size_hint=(0.9, 0.9)) 595 | self.roi_selector.enable_mouse_move_event(False) 596 | self._popup.content = SfrViewer(sfr_dict=outputs, cancel=self._popup.dismiss, export_prefix=os.path.basename(os.path.splitext(image_path)[0])) 597 | def dismiss_callback(instance): 598 | self.roi_selector.enable_mouse_move_event(True) 599 | Window.unbind(mouse_pos=self._popup.content.on_mouse_move) 600 | self._popup.bind(on_dismiss=dismiss_callback) 601 | self._popup.open() 602 | Clock.schedule_once(calculate_sfr) 603 | 604 | def close_roi_selector(self): 605 | self.remove_widget(self.roi_selector) 606 | self.roi_selector = None 607 | 608 | class SfrApp(App): 609 | title = 'SFR Calculator' 610 | 611 | def build(self): 612 | self.workspace = Workspace() 613 | return self.workspace 614 | 615 | if __name__ == '__main__': 616 | SfrApp().run() 617 | -------------------------------------------------------------------------------- /sfr.kv: -------------------------------------------------------------------------------- 1 | #:import Gradient main.Gradient 2 | #:import os os 3 | #:import pathlib pathlib 4 | : 5 | font_name: 'TaipeiSansTCBeta-Regular.ttf' 6 | 7 | : 8 | canvas: 9 | Rectangle: 10 | size: self.size 11 | texture: Gradient.vertical((.1,.1,.1,1),(.25,.25,.25,1)) 12 | Label: 13 | text: 'Click to load an image file' 14 | font_size: '24sp' 15 | 16 | : 17 | BoxLayout: 18 | size: root.size 19 | pos: root.pos 20 | orientation: "horizontal" 21 | 22 | Splitter: 23 | sizable_from: 'right' 24 | max_size: root.width - self.min_size 25 | 26 | BoxLayout: 27 | orientation: "vertical" 28 | 29 | Spinner: 30 | id: spinner 31 | size_hint_max_y: 1 32 | opacity: 0 33 | 34 | FileChooserIconView: 35 | id: filechooser 36 | path: str(pathlib.Path.home()) 37 | filters: ['*.png','*.jpg','*.bmp'] 38 | on_selection: if len(filechooser.selection) > 0: root.update(os.path.join(filechooser.path, filechooser.selection[0]), image) 39 | on_entry_added: root.update(os.path.join(filechooser.path, filechooser.selection[0]), image) if len(filechooser.selection) > 0 else root.update(filechooser.path, image) 40 | 41 | 42 | BoxLayout: 43 | orientation: "vertical" 44 | 45 | Image: 46 | id: image 47 | size_hint: 1, 1 48 | color: 0,0,0,0 49 | 50 | BoxLayout: 51 | size_hint_y: None 52 | height: 30 53 | Button: 54 | text: "Cancel" 55 | on_release: root.cancel() 56 | 57 | Button: 58 | text: "Load" 59 | on_release: if len(filechooser.selection) > 0: root.load(os.path.join(filechooser.path, filechooser.selection[0])) 60 | 61 | : 62 | size_hint: 1, 1 63 | 64 | image: image 65 | 66 | Image: 67 | id: image 68 | allow_stretch: True 69 | source: root.source 70 | 71 | : 72 | size_hint: None, None 73 | arrow_pos: 'bottom_left' 74 | 75 | BoxLayout: 76 | orientation: "vertical" 77 | 78 | Label: 79 | id: label 80 | text_size: root.width, None 81 | text: root.message 82 | bold: True 83 | halign: 'center' 84 | valign: 'middle' 85 | size: self.texture_size 86 | 87 | BoxLayout: 88 | orientation: "horizontal" 89 | 90 | BubbleButton: 91 | text: 'Yes' 92 | bold: True 93 | on_release: root.load() 94 | BubbleButton: 95 | text: 'No' 96 | bold: True 97 | on_release: root.cancel() 98 | 99 | : 100 | arrow_pos: 'bottom_mid' 101 | size_hint: None, None 102 | 103 | Label: 104 | text_size: root.width, None 105 | size: self.texture_size 106 | text: root.message 107 | bold: True 108 | halign: 'center' 109 | valign: 'middle' 110 | -------------------------------------------------------------------------------- /sfr.py: -------------------------------------------------------------------------------- 1 | import csv 2 | import os 3 | import time 4 | 5 | from PIL import Image 6 | 7 | import numpy 8 | 9 | from scipy import stats 10 | from scipy.fftpack import fft 11 | 12 | class SFR(): 13 | image_path = "" 14 | image_roi = (0,0,0,0) 15 | gamma = 0 16 | oversampling_rate = 0 17 | outputs = None 18 | 19 | def __init__(self, image_path, image_roi, gamma = 0.5, oversampling_rate = 4): 20 | self.image_path = image_path 21 | self.image_roi = image_roi 22 | self.gamma = gamma 23 | self.oversampling_rate = oversampling_rate 24 | 25 | def calculate(self, export_csv = None): 26 | # use PIL to open the image 27 | image = Image.open(self.image_path).convert('RGB') 28 | # store the original image size 29 | image_size = image.size 30 | # get the ROI image 31 | image = image.crop(self.image_roi) 32 | # automatically rotate the image if its width is greater than height 33 | if image.width > image.height: 34 | image = image.transpose(Image.ROTATE_90) 35 | 36 | # create dictionary for output data 37 | outputs = {} 38 | outputs['File'] = self.image_path 39 | outputs['Size'] = image_size 40 | outputs['ROI'] = self.image_roi 41 | outputs['Gamma'] = self.gamma 42 | outputs['Oversampling'] = self.oversampling_rate 43 | outputs['Orientation'] = 'Horizontal' if (self.image_roi[2]-self.image_roi[0]) > (self.image_roi[3]-self.image_roi[1]) else 'Vertical' 44 | outputs['Run Date'] = time.asctime(time.localtime(time.time())) 45 | # do the same process in R/G/B/L channels 46 | channels = ['L'] 47 | channels += list(image.getbands()) 48 | channels_data = {} 49 | for c in channels: 50 | if c == 'L': 51 | # extract pixel data from the grayscale image 52 | pixel_list = list(image.convert(c).getdata()) 53 | else: 54 | # extract pixel data from the selected band 55 | pixel_list = list(image.getdata(band=(channels.index(c)-1))) 56 | # undo gamma correction 57 | pixel_list = [ p**(1/self.gamma) for p in pixel_list ] 58 | # create 2d array 59 | pixel_array = numpy.array(pixel_list).reshape(image.height, image.width) 60 | # get ESF data 61 | esf_data, slope, intercept = self._get_esf_data(pixel_array, self.oversampling_rate) 62 | # get LSF data 63 | lsf_data = self._get_lsf_data(esf_data) 64 | # get SFR data 65 | sfr_data = self._get_sfr_data(lsf_data) 66 | # get MTF data 67 | mtf_data, mtf50, mtf50p = self._get_mtf_data(sfr_data, self.oversampling_rate) 68 | # get standardized sharpening MTF data 69 | standard_mtf_data, standard_mtf50, mtf_equal, k_sharp, sharpening_radius = self._get_standard_mtf_data(mtf_data, mtf50, self.oversampling_rate) 70 | # export results 71 | if 'Cy/Pxl' not in outputs: 72 | outputs['Cy/Pxl'] = numpy.linspace(0.0, 1.0, len(mtf_data)) 73 | if 'LW/PH' not in outputs: 74 | outputs['LW/PH'] = numpy.linspace(0.0, image_size[0 if outputs['Orientation'] == 'Horizontal' else 1]*2, len(mtf_data)) 75 | data = {} 76 | data['ESF'] = esf_data 77 | data['LSF'] = lsf_data 78 | data['MTF'] = mtf_data 79 | data['MTF50'] = mtf50 80 | data['MTF50P'] = mtf50p 81 | data['Sharpening'] = (mtf_equal-1) 82 | data['Corrected MTF'] = standard_mtf_data 83 | data['Corrected MTF50'] = standard_mtf50 84 | # NOTE: need to revese the slope since (0, 0) in the image is at the top left corner rahter than at the bottom left corner 85 | data['Edge Angle'] = numpy.arctan(-slope)*180/numpy.pi 86 | data['Sharpening Radius'] = sharpening_radius 87 | channels_data[c]=data 88 | outputs['Channels']=channels_data 89 | self.outputs = outputs 90 | 91 | if (export_csv): 92 | self._export_csv_file(export_csv, outputs) 93 | 94 | return outputs 95 | 96 | def convert_outputs_to_csv_files(export_csv, outputs): 97 | self._export_csv_file(export_csv, outputs) 98 | 99 | def _get_esf_data(self, pixel_array, oversampling_rate): 100 | edge_idx_per_line = [] 101 | # find edge positions 102 | for line in pixel_array: 103 | max_diff=0 104 | last_px = line[0] 105 | max_idx = idx = 0 106 | for px in line: 107 | diff = abs(last_px - px) 108 | if diff > max_diff: 109 | max_diff = diff 110 | max_idx = idx 111 | last_px = px 112 | idx += 1 113 | edge_idx_per_line.append(max_idx) 114 | # get line regression result for projection 115 | slope, intercept, r_value, p_value, std_err = stats.linregress(list(range(len(edge_idx_per_line))), edge_idx_per_line) 116 | # get inspection width 117 | inspection_width = 1 118 | # TODO: check if we should remove then "=" condition in the if statement 119 | while inspection_width <= len(pixel_array[0]): 120 | inspection_width *= 2 121 | inspection_width = inspection_width//2 122 | half_inspection_width = inspection_width/2 123 | # do edge spread function 124 | esf_sum = [0] * (inspection_width*oversampling_rate + 2) 125 | hit_count = [0] * (inspection_width*oversampling_rate + 2) 126 | x = y = 0 127 | for line in pixel_array: 128 | for px in line: 129 | # only calculate the pixels in the inspection width 130 | if abs(x-(y*slope+intercept)) <= half_inspection_width+1/oversampling_rate: 131 | idx = int((x-(y*slope+intercept)+half_inspection_width)*oversampling_rate+1) 132 | esf_sum[idx] += px 133 | hit_count[idx] += 1 134 | x += 1 135 | y += 1 136 | x = 0 137 | # force hit count to 1 if it's 0 to avoid calculation error 138 | # TODO: we should lower the oversampling rate or shutdown the SFR process if a hit count is 0 139 | hit_count = [ 1 if c == 0 else c for c in hit_count ] 140 | return numpy.divide(esf_sum, hit_count).tolist(), slope, intercept 141 | 142 | def _get_lsf_data(self, esf_data): 143 | # do line spread function 144 | lsf_data = [0] * (len(esf_data)-2) 145 | idx = 0 146 | for v in lsf_data: 147 | # the 3-point derivative 148 | lsf_data[idx] = (esf_data[idx+2] - esf_data[idx]) / 2 149 | idx += 1 150 | return lsf_data 151 | 152 | def _get_sfr_data(self, lsf_data): 153 | # use hamming window to reduce the effects of the Gibbs phenomenon 154 | hamming_window = numpy.hamming(len(lsf_data)).tolist() 155 | windowed_lsf_data = numpy.multiply(lsf_data, hamming_window).tolist() 156 | raw_sfr_data = numpy.abs(fft(windowed_lsf_data)).tolist() 157 | sfr_base = raw_sfr_data[0] 158 | return [ d/sfr_base for d in raw_sfr_data ] 159 | 160 | def _get_mtf_data(self, sfr_data, oversampling_rate): 161 | # When PictureHeight = 2448 & every 2 pixels are corresponding to 1 line pair, then 2448 (LW/PH) = 1224 (LP/PH) = 0.5 (Cy/Pxl) = Nyquist Frequency. 162 | peak_mtf = freq_at_50p_mtf = freq_at_50_mtf = 0 163 | # 1. The SFR series is a symmetry series, so we only need the first half of the series to do the calculation 164 | # 2. In the original image (oversampling = 1), the valid frequency is 0 ~ 0.5. So the valid frequency after the oversampling is 0 ~ 0.5 * oversmapling rate. 165 | # Since we only care about the range from 0 to 1, we should also truncate the series here. 166 | mtf_data = [0] * int(len(sfr_data)/2/(oversampling_rate*0.5)) 167 | idx = 0 168 | for sfr in sfr_data[0:len(mtf_data)]: 169 | # frequency is from 0 to 1 170 | freq = idx / (len(mtf_data)-1) 171 | # divide by a corrective factor MTF(deriv) 172 | # MTF(system) = SFR / FR / MTF(deriv) 173 | # MTF(deriv) = sin(PI*f*k*(1/overSamplingFactor)) / (PI*f*k*(1/overSamplingFactor)); k=2 (for the 3-point derivative), k=1 (for the 2-point derivative) 174 | if freq == 0: 175 | mtf_data[idx] = sfr 176 | else: 177 | mtf_data[idx] = sfr*(numpy.pi*freq*2/oversampling_rate)/numpy.sin(numpy.pi*freq*2/oversampling_rate) 178 | # get MTF50 179 | if freq_at_50_mtf == 0 and mtf_data[idx] < 0.5: 180 | freq_at_50_mtf = (idx-1+(0.5-mtf_data[idx])/(mtf_data[idx-1]-mtf_data[idx]))/(len(mtf_data)-1) 181 | # get MTF50P 182 | if peak_mtf < mtf_data[idx]: 183 | peak_mtf = mtf_data[idx] 184 | if freq_at_50p_mtf == 0 and mtf_data[idx] < 0.5*peak_mtf: 185 | freq_at_50p_mtf = (idx-1+(0.5*peak_mtf-mtf_data[idx])/(mtf_data[idx-1]-mtf_data[idx]))/(len(mtf_data)-1) 186 | idx += 1 187 | return mtf_data, freq_at_50_mtf, freq_at_50p_mtf 188 | 189 | def _get_standard_mtf_data(self, mtf_data, freq_at_50_mtf, oversampling_rate): 190 | if freq_at_50_mtf < 0.2: 191 | freq_equal = 0.6 * freq_at_50_mtf 192 | sharpening_radius = 3 193 | else: 194 | freq_equal = 0.15 195 | sharpening_radius = 2 196 | idx_equal = freq_equal * (len(mtf_data)-1) 197 | mtf_equal = mtf_data[int(idx_equal)] + (mtf_data[int(idx_equal)+1]-mtf_data[int(idx_equal)])*(idx_equal-idx_equal//1) 198 | last_sharpening_radius = 0 199 | while last_sharpening_radius != sharpening_radius: 200 | last_sharpening_radius = sharpening_radius 201 | # calculate sharpness coefficient 202 | # MTF(system) = MTF(standard) * MTF(sharp) = MTF(system) * (1 - ksharp * cos(2*PI*f*R/dscan)) / (1- ksharp) 203 | # When MTF(sharp) = 1, ksharp = (1 - MTF(system)) / (cos(2*PI*f*R/dscan) - MTF(system)) 204 | k_sharp = (1-mtf_equal)/(numpy.cos(2*numpy.pi*freq_equal*sharpening_radius)-mtf_equal) 205 | # standardized sharpening 206 | standard_freq_at_50_mtf = 0 207 | idx = 0 208 | standard_mtf_data = [0] * len(mtf_data) 209 | for mtf in mtf_data: 210 | # frequency is from 0 to 1 211 | freq = idx / (len(mtf_data)-1) 212 | standard_mtf_data[idx] = mtf/((1-k_sharp*numpy.cos(2*numpy.pi*freq*sharpening_radius))/(1-k_sharp)) 213 | # get MTF50 214 | if standard_freq_at_50_mtf == 0 and standard_mtf_data[idx] < 0.5: 215 | standard_freq_at_50_mtf = (idx-1+(0.5-standard_mtf_data[idx])/(standard_mtf_data[idx-1]-standard_mtf_data[idx]))/(len(standard_mtf_data)-1) 216 | # If the difference of the original frequency at MTF50 and the frequency at MTF50(corr) is larger than 0.04, 217 | # it should increase the radius by one and recalculate the ksharp. 218 | if (abs(standard_freq_at_50_mtf-freq_at_50_mtf) > 0.04): 219 | sharpening_radius += 1 220 | break 221 | idx += 1 222 | return standard_mtf_data, standard_freq_at_50_mtf, mtf_equal, k_sharp, sharpening_radius 223 | 224 | def _export_csv_file(self, csv_file_name, outputs): 225 | # TODO: Find a more efficient way to export a CSV file 226 | with open(csv_file_name, 'w', newline='') as csv_file: 227 | writer = csv.writer(csv_file) 228 | # export generic data 229 | writer.writerow(['File', os.path.basename(outputs['File'])]) 230 | writer.writerow(['Run Date', outputs['Run Date']]) 231 | writer.writerow(['Path', os.path.dirname(outputs['File'])]) 232 | writer.writerow('') 233 | # export misc data 234 | writer.writerow(['Slice Orientation', outputs['Orientation']]) 235 | writer.writerow(['Image WxH pixels', outputs['Size'][0], outputs['Size'][1]]) 236 | writer.writerow(['ROI WxH pixels', outputs['ROI'][2]-outputs['ROI'][0], outputs['ROI'][3]-outputs['ROI'][1]]) 237 | writer.writerow(['ROI boundary L T R B', outputs['ROI'][0], outputs['ROI'][1], outputs['ROI'][2], outputs['ROI'][3]]) 238 | writer.writerow(['Gamma', outputs['Gamma']]) 239 | writer.writerow(['Oversmapling', outputs['Oversampling']]) 240 | header = ['Channel'] 241 | for c in outputs['Channels'].keys(): 242 | header.append(c) 243 | writer.writerow(header) 244 | data = ['Edge Angle'] 245 | for c in outputs['Channels'].keys(): 246 | data.append(round(outputs['Channels'][c]['Edge Angle'],4)) 247 | writer.writerow(data) 248 | data = ['MTF50 Cy/pxl (uncorr)'] 249 | for c in outputs['Channels'].keys(): 250 | data.append(round(outputs['Channels'][c]['MTF50'],4)) 251 | writer.writerow(data) 252 | data = ['MTF50 LW/PH (uncorr)'] 253 | for c in outputs['Channels'].keys(): 254 | data.append(round(outputs['Channels'][c]['MTF50']*outputs['Size'][0 if outputs['Orientation'] == 'Horizontal' else 1]*2)) 255 | writer.writerow(data) 256 | data = ['MTF50 Cy/pxl (corr)'] 257 | for c in outputs['Channels'].keys(): 258 | data.append(round(outputs['Channels'][c]['Corrected MTF50'],4)) 259 | writer.writerow(data) 260 | data = ['MTF50 LW/PH (corr)'] 261 | for c in outputs['Channels'].keys(): 262 | data.append(round(outputs['Channels'][c]['Corrected MTF50']*outputs['Size'][0 if outputs['Orientation'] == 'Horizontal' else 1]*2)) 263 | writer.writerow(data) 264 | data = ['MTF50P Cy/pxl'] 265 | for c in outputs['Channels'].keys(): 266 | data.append(round(outputs['Channels'][c]['MTF50P'],4)) 267 | writer.writerow(data) 268 | data = ['MTF50P LW/PH'] 269 | for c in outputs['Channels'].keys(): 270 | data.append(round(outputs['Channels'][c]['MTF50P']*outputs['Size'][0 if outputs['Orientation'] == 'Horizontal' else 1]*2)) 271 | writer.writerow(data) 272 | data = ['Oversharpening %'] 273 | for c in outputs['Channels'].keys(): 274 | data.append(round(outputs['Channels'][c]['Sharpening']*100,2)) 275 | writer.writerow(data) 276 | data = ['Sharpening Radius'] 277 | for c in outputs['Channels'].keys(): 278 | data.append(outputs['Channels'][c]['Sharpening Radius']) 279 | writer.writerow(data) 280 | writer.writerow('') 281 | # export MTF data 282 | header = ['Cy/Pxl','LW/PH'] 283 | for c in outputs['Channels'].keys(): 284 | header.append('MTF({0:s})'.format(c)) 285 | header.append('MTF({0:s} corr)'.format(c)) 286 | writer.writerow(header) 287 | for i in range(len(outputs['Cy/Pxl'])): 288 | data = [round(outputs['Cy/Pxl'][i],4), round(outputs['LW/PH'][i],2)] 289 | for c in outputs['Channels'].keys(): 290 | data.append(round(outputs['Channels'][c]['MTF'][i],4)) 291 | data.append(round(outputs['Channels'][c]['Corrected MTF'][i],4)) 292 | writer.writerow(data) 293 | writer.writerow('') 294 | # export LSF data 295 | header = ['x (pixels)'] 296 | for c in outputs['Channels'].keys(): 297 | header.append('{0:s} Edge'.format(c)) 298 | writer.writerow(header) 299 | data_length = len(outputs['Channels']['L']['LSF']) 300 | oversampling_rate = outputs['Oversampling'] 301 | for i in range(data_length): 302 | data = [(i-data_length/2)/oversampling_rate] 303 | for c in outputs['Channels'].keys(): 304 | data.append(round(outputs['Channels'][c]['LSF'][i],2)) 305 | writer.writerow(data) 306 | writer.writerow('') 307 | # export ESF data 308 | header = ['x (pixels)'] 309 | for c in outputs['Channels'].keys(): 310 | header.append('{0:s} Level'.format(c)) 311 | writer.writerow(header) 312 | data_length = len(outputs['Channels']['L']['ESF']) 313 | for i in range(data_length): 314 | data = [(i-data_length/2)/oversampling_rate] 315 | for c in outputs['Channels'].keys(): 316 | data.append(round(outputs['Channels'][c]['ESF'][i],0)) 317 | writer.writerow(data) 318 | writer.writerow('') 319 | -------------------------------------------------------------------------------- /sfr_calculator.spec: -------------------------------------------------------------------------------- 1 | # -*- mode: python ; coding: utf-8 -*- 2 | from kivy_deps import sdl2, glew 3 | 4 | block_cipher = None 5 | 6 | 7 | a = Analysis(['main.py'], 8 | pathex=['D:\\Downloads\\python_sfr'], 9 | binaries=[], 10 | datas=[], 11 | hiddenimports=[], 12 | hookspath=[], 13 | runtime_hooks=[], 14 | excludes=[], 15 | win_no_prefer_redirects=False, 16 | win_private_assemblies=False, 17 | cipher=block_cipher, 18 | noarchive=False) 19 | pyz = PYZ(a.pure, a.zipped_data, 20 | cipher=block_cipher) 21 | exe = EXE(pyz, 22 | a.scripts, 23 | [], 24 | exclude_binaries=True, 25 | name='sfr_calculator', 26 | debug=False, 27 | bootloader_ignore_signals=False, 28 | strip=False, 29 | upx=True, 30 | console=True ) 31 | coll = COLLECT(exe, Tree('.\\'), 32 | a.binaries, 33 | a.zipfiles, 34 | a.datas, 35 | *[Tree(p) for p in (sdl2.dep_bins + glew.dep_bins)], 36 | strip=False, 37 | upx=True, 38 | upx_exclude=[], 39 | name='sfr_calculator') 40 | --------------------------------------------------------------------------------