├── requirements.txt ├── televinken.jpg ├── edge_samples ├── x0552_y1976_F16.png ├── x2084_y0616_F2.png └── x2088_y0616_F2_8.png ├── televinken.jpg.json ├── test_pgm_P2.pgm ├── execution_timer.py ├── .gitignore ├── SFR_example.py ├── COPYING.txt ├── SFR_test.py ├── selection_tools.py ├── slanted_edge_target.py ├── SFR.py └── utils.py /requirements.txt: -------------------------------------------------------------------------------- 1 | matplotlib~=3.6.3 2 | numpy~=1.24.1 3 | scipy~=1.10.0 -------------------------------------------------------------------------------- /televinken.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/carlasplund/SFR_calculation_code/HEAD/televinken.jpg -------------------------------------------------------------------------------- /edge_samples/x0552_y1976_F16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/carlasplund/SFR_calculation_code/HEAD/edge_samples/x0552_y1976_F16.png -------------------------------------------------------------------------------- /edge_samples/x2084_y0616_F2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/carlasplund/SFR_calculation_code/HEAD/edge_samples/x2084_y0616_F2.png -------------------------------------------------------------------------------- /edge_samples/x2088_y0616_F2_8.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/carlasplund/SFR_calculation_code/HEAD/edge_samples/x2088_y0616_F2_8.png -------------------------------------------------------------------------------- /televinken.jpg.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "x": 147, 4 | "y": 49, 5 | "height": 80, 6 | "width": 80 7 | }, 8 | { 9 | "x": 42, 10 | "y": 119, 11 | "height": 30, 12 | "width": 70 13 | } 14 | ] -------------------------------------------------------------------------------- /test_pgm_P2.pgm: -------------------------------------------------------------------------------- 1 | P2 2 | # feep.pgm 3 | 24 7 4 | 15 5 | 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 6 | 0 3 3 3 3 0 0 7 7 7 7 0 0 11 11 11 11 0 0 15 15 15 15 0 7 | 0 3 0 0 0 0 0 7 0 0 0 0 0 11 0 0 0 0 0 15 0 0 15 0 8 | 0 3 3 3 0 0 0 7 7 7 0 0 0 11 11 11 0 0 0 15 15 15 15 0 9 | 0 3 0 0 0 0 0 7 0 0 0 0 0 11 0 0 0 0 0 15 0 0 0 0 10 | 0 3 0 0 0 0 0 7 7 7 7 0 0 11 11 11 11 0 0 15 0 0 0 0 11 | 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 -------------------------------------------------------------------------------- /execution_timer.py: -------------------------------------------------------------------------------- 1 | import time 2 | 3 | 4 | def execution_timer(func): 5 | def wrapper(*args, **kwargs): 6 | t0 = time.time() 7 | val = func(*args, **kwargs) 8 | print(f"Function '{func.__name__:s}' took {time.time() - t0:.3f} s to execute.") 9 | return val 10 | 11 | return wrapper 12 | 13 | 14 | if __name__ == '__main__': 15 | 16 | # Example of use. Put the decorator in front of the function 17 | # to be timed, like this: 18 | 19 | @execution_timer 20 | def run(t): 21 | time.sleep(t) 22 | 23 | 24 | run(3) 25 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | wheels/ 23 | pip-wheel-metadata/ 24 | share/python-wheels/ 25 | *.egg-info/ 26 | .installed.cfg 27 | *.egg 28 | MANIFEST 29 | 30 | # PyInstaller 31 | # Usually these files are written by a python script from a template 32 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 33 | *.manifest 34 | *.spec 35 | 36 | # Installer logs 37 | pip-log.txt 38 | pip-delete-this-directory.txt 39 | 40 | # Unit test / coverage reports 41 | htmlcov/ 42 | .tox/ 43 | .nox/ 44 | .coverage 45 | .coverage.* 46 | .cache 47 | nosetests.xml 48 | coverage.xml 49 | *.cover 50 | *.py,cover 51 | .hypothesis/ 52 | .pytest_cache/ 53 | 54 | # Translations 55 | *.mo 56 | *.pot 57 | 58 | # Django stuff: 59 | *.log 60 | local_settings.py 61 | db.sqlite3 62 | db.sqlite3-journal 63 | 64 | # Flask stuff: 65 | instance/ 66 | .webassets-cache 67 | 68 | # Scrapy stuff: 69 | .scrapy 70 | 71 | # Sphinx documentation 72 | docs/_build/ 73 | 74 | # PyBuilder 75 | target/ 76 | 77 | # Jupyter Notebook 78 | .ipynb_checkpoints 79 | 80 | # IPython 81 | profile_default/ 82 | ipython_config.py 83 | 84 | # pyenv 85 | .python-version 86 | 87 | # pipenv 88 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 89 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 90 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 91 | # install all needed dependencies. 92 | #Pipfile.lock 93 | 94 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow 95 | __pypackages__/ 96 | 97 | # Celery stuff 98 | celerybeat-schedule 99 | celerybeat.pid 100 | 101 | # SageMath parsed files 102 | *.sage.py 103 | 104 | # Environments 105 | .env 106 | .venv 107 | env/ 108 | venv/ 109 | ENV/ 110 | env.bak/ 111 | venv.bak/ 112 | 113 | # Spyder project settings 114 | .spyderproject 115 | .spyproject 116 | 117 | # Rope project settings 118 | .ropeproject 119 | 120 | # mkdocs documentation 121 | /site 122 | 123 | # mypy 124 | .mypy_cache/ 125 | .dmypy.json 126 | dmypy.json 127 | 128 | # Pyre type checker 129 | .pyre/ 130 | .idea/ 131 | *.png 132 | -------------------------------------------------------------------------------- /SFR_example.py: -------------------------------------------------------------------------------- 1 | import matplotlib.pyplot as plt 2 | import numpy as np 3 | import scipy 4 | import os 5 | 6 | 7 | def get_parameters_from_file_name(im_filename): 8 | basename = os.path.basename(im_filename) 9 | name_parts = basename.split('_') 10 | 11 | x = int(name_parts[0].split('x')[1]) 12 | y = int(name_parts[1].split('y')[1]) 13 | 14 | def is_in(search_strings, name): 15 | return any(s for s in search_strings if s in name) # return true if any of the strings occur in name 16 | 17 | # The f-number may not be written in a consistent way in the folder name, so we employ a brute force parsing 18 | # method that accounts for several versions in use. :) Add new variants to this list as necessary. 19 | key_list = [ 20 | {'str_variants': ['F1_6'], 'f_num': 1.6}, 21 | {'str_variants': ['f1_8'], 'f_num': 1.8}, 22 | {'str_variants': ['f2_8', 'f_2_8', 'F2_8'], 'f_num': 2.8}, 23 | {'str_variants': ['F2'], 'f_num': 2.0}, 24 | {'str_variants': ['F4'], 'f_num': 4.0}, 25 | {'str_variants': ['f5_6', 'f_5_6', 'f_56', 'F5_6'], 'f_num': 5.6}, 26 | {'str_variants': ['F8'], 'f_num': 8.0}, 27 | {'str_variants': ['f11', 'f_11', 'F11'], 'f_num': 11.0}, 28 | {'str_variants': ['f16', 'f_16', 'F16'], 'f_num': 16.0}, 29 | ] 30 | 31 | f_number = np.nan 32 | for key in key_list: 33 | if is_in(key['str_variants'], basename): 34 | f_number = key['f_num'] 35 | break 36 | 37 | return x, y, f_number 38 | 39 | 40 | def select_ROI_and_calc_MTF(folder, save_folder, im_filename, pixel_pitch, read_fcn, orig_ext, 41 | specification_freqs, dpi=200, lam_diffr=550e-9): 42 | import utils 43 | 44 | full_im_path = os.path.join(folder, im_filename) 45 | folder_basename = os.path.basename(folder) 46 | print(f'Processing {os.path.join(folder_basename, im_filename)}') 47 | 48 | im_roi = read_fcn(full_im_path) # read image data 49 | if len(im_roi.shape) > 2: 50 | im_roi = im_roi[:, :, 0] # TODO find a more general way to handle RGB and RGBA images ************************ 51 | roi_height, roi_width = im_roi.shape 52 | 53 | x, y, f_number = get_parameters_from_file_name(im_filename) 54 | 55 | # Prepare MTF curve plot object 56 | mtf_plotter = utils.MTFplotter(pixel_pitch, f_number, lam_diffr=lam_diffr, fit_begin=[0.54, 0.63], 57 | fit_end=[0.90, 0.81], mtf_fit_limit=[0.40], mtf_tail_lvl=0.05) 58 | 59 | for x_lims, range_str in zip([(0, 120), (0, 400)], ['', '_0to400cymm']): 60 | fig, [ax1, ax2] = plt.subplots(1, 2, figsize=(12, 4)) 61 | 62 | ax1.imshow(im_roi, vmin=0.0, vmax=1.0, cmap='gray') 63 | 64 | # Plot MTF curves and return MTF data 65 | mtf_system, mtf_lens, status = mtf_plotter.calc_and_plot_mtf(ax2, im_roi, x_lims=x_lims) 66 | 67 | angle = status['angle'] # edge angle relative to vertical 68 | suptitle = f'(x, y) = ({x:d}, {y:d}), {roi_width:d}x{roi_height:d} px, f/{f_number:.1f}, edge: {angle:.1f}°' 69 | fig.suptitle(suptitle) 70 | 71 | file_basename = os.path.join(save_folder, im_filename).split(orig_ext)[0] + f'ROI_h{roi_height}_w{roi_width}' 72 | file_image_save_name = file_basename + f'_mtf{range_str}.png' 73 | plt.savefig(file_image_save_name, dpi=dpi) 74 | 75 | file_data_save_name = file_basename + f'_mtf_system.txt' 76 | np.savetxt(file_data_save_name, mtf_system, fmt='%.4e') 77 | 78 | file_data_save_name = file_basename + f'_mtf_lens.txt' 79 | np.savetxt(file_data_save_name, mtf_lens, fmt='%.4e') 80 | 81 | # Obtain lens MTF at the design specification frequencies 82 | mtf_lens_interp = scipy.interpolate.interp1d(mtf_lens[:, 0], mtf_lens[:, 1]) 83 | for spatial_freq in specification_freqs: 84 | mtf_at_sf = mtf_lens_interp(spatial_freq)[()] 85 | print(f'MTF at {spatial_freq:.0f} cy/mm is {mtf_at_sf * 100:.0f}%') 86 | 87 | 88 | def test(): 89 | import utils 90 | 91 | folder = 'edge_samples' 92 | im_names = os.listdir(folder) 93 | im_names = [f for f in im_names if '.txt' not in f] 94 | # TODO write a more general read function that automatically uses the correct read format 95 | if any('.bmp' in f for f in im_names): 96 | read_fcn, orig_ext = utils.read_8bit, '.bmp' 97 | if any('.png' in f for f in im_names): 98 | read_fcn, orig_ext = utils.read_8bit, '.png' 99 | if any('.pgm' in f for f in im_names): 100 | read_fcn, orig_ext = utils.read_pgm, '.pgm' 101 | im_names = [f for f in im_names if orig_ext in f] 102 | 103 | for im_name in im_names: 104 | save_folder = folder + '_eval' 105 | os.makedirs(save_folder, exist_ok=True) 106 | pixel_pitch = 5.0 # pixel pitch (µm) of the image sensor 107 | lam_diffr = 550e-9 # wavelength (nm) for which the diffraction limit MTF is calculated 108 | specification_freqs = [60.0, 90.0] # spatial frequencies (cy/mm) of special interest for the MTF 109 | select_ROI_and_calc_MTF(folder, save_folder, im_name, pixel_pitch, read_fcn, orig_ext, 110 | specification_freqs, lam_diffr=lam_diffr) 111 | 112 | 113 | if __name__ == '__main__': 114 | test() 115 | -------------------------------------------------------------------------------- /COPYING.txt: -------------------------------------------------------------------------------- 1 | Creative Commons Legal Code 2 | 3 | CC0 1.0 Universal 4 | 5 | CREATIVE COMMONS CORPORATION IS NOT A LAW FIRM AND DOES NOT PROVIDE 6 | LEGAL SERVICES. DISTRIBUTION OF THIS DOCUMENT DOES NOT CREATE AN 7 | ATTORNEY-CLIENT RELATIONSHIP. CREATIVE COMMONS PROVIDES THIS 8 | INFORMATION ON AN "AS-IS" BASIS. CREATIVE COMMONS MAKES NO WARRANTIES 9 | REGARDING THE USE OF THIS DOCUMENT OR THE INFORMATION OR WORKS 10 | PROVIDED HEREUNDER, AND DISCLAIMS LIABILITY FOR DAMAGES RESULTING FROM 11 | THE USE OF THIS DOCUMENT OR THE INFORMATION OR WORKS PROVIDED 12 | HEREUNDER. 13 | 14 | Statement of Purpose 15 | 16 | The laws of most jurisdictions throughout the world automatically confer 17 | exclusive Copyright and Related Rights (defined below) upon the creator 18 | and subsequent owner(s) (each and all, an "owner") of an original work of 19 | authorship and/or a database (each, a "Work"). 20 | 21 | Certain owners wish to permanently relinquish those rights to a Work for 22 | the purpose of contributing to a commons of creative, cultural and 23 | scientific works ("Commons") that the public can reliably and without fear 24 | of later claims of infringement build upon, modify, incorporate in other 25 | works, reuse and redistribute as freely as possible in any form whatsoever 26 | and for any purposes, including without limitation commercial purposes. 27 | These owners may contribute to the Commons to promote the ideal of a free 28 | culture and the further production of creative, cultural and scientific 29 | works, or to gain reputation or greater distribution for their Work in 30 | part through the use and efforts of others. 31 | 32 | For these and/or other purposes and motivations, and without any 33 | expectation of additional consideration or compensation, the person 34 | associating CC0 with a Work (the "Affirmer"), to the extent that he or she 35 | is an owner of Copyright and Related Rights in the Work, voluntarily 36 | elects to apply CC0 to the Work and publicly distribute the Work under its 37 | terms, with knowledge of his or her Copyright and Related Rights in the 38 | Work and the meaning and intended legal effect of CC0 on those rights. 39 | 40 | 1. Copyright and Related Rights. A Work made available under CC0 may be 41 | protected by copyright and related or neighboring rights ("Copyright and 42 | Related Rights"). Copyright and Related Rights include, but are not 43 | limited to, the following: 44 | 45 | i. the right to reproduce, adapt, distribute, perform, display, 46 | communicate, and translate a Work; 47 | ii. moral rights retained by the original author(s) and/or performer(s); 48 | iii. publicity and privacy rights pertaining to a person's image or 49 | likeness depicted in a Work; 50 | iv. rights protecting against unfair competition in regards to a Work, 51 | subject to the limitations in paragraph 4(a), below; 52 | v. rights protecting the extraction, dissemination, use and reuse of data 53 | in a Work; 54 | vi. database rights (such as those arising under Directive 96/9/EC of the 55 | European Parliament and of the Council of 11 March 1996 on the legal 56 | protection of databases, and under any national implementation 57 | thereof, including any amended or successor version of such 58 | directive); and 59 | vii. other similar, equivalent or corresponding rights throughout the 60 | world based on applicable law or treaty, and any national 61 | implementations thereof. 62 | 63 | 2. Waiver. To the greatest extent permitted by, but not in contravention 64 | of, applicable law, Affirmer hereby overtly, fully, permanently, 65 | irrevocably and unconditionally waives, abandons, and surrenders all of 66 | Affirmer's Copyright and Related Rights and associated claims and causes 67 | of action, whether now known or unknown (including existing as well as 68 | future claims and causes of action), in the Work (i) in all territories 69 | worldwide, (ii) for the maximum duration provided by applicable law or 70 | treaty (including future time extensions), (iii) in any current or future 71 | medium and for any number of copies, and (iv) for any purpose whatsoever, 72 | including without limitation commercial, advertising or promotional 73 | purposes (the "Waiver"). Affirmer makes the Waiver for the benefit of each 74 | member of the public at large and to the detriment of Affirmer's heirs and 75 | successors, fully intending that such Waiver shall not be subject to 76 | revocation, rescission, cancellation, termination, or any other legal or 77 | equitable action to disrupt the quiet enjoyment of the Work by the public 78 | as contemplated by Affirmer's express Statement of Purpose. 79 | 80 | 3. Public License Fallback. Should any part of the Waiver for any reason 81 | be judged legally invalid or ineffective under applicable law, then the 82 | Waiver shall be preserved to the maximum extent permitted taking into 83 | account Affirmer's express Statement of Purpose. In addition, to the 84 | extent the Waiver is so judged Affirmer hereby grants to each affected 85 | person a royalty-free, non transferable, non sublicensable, non exclusive, 86 | irrevocable and unconditional license to exercise Affirmer's Copyright and 87 | Related Rights in the Work (i) in all territories worldwide, (ii) for the 88 | maximum duration provided by applicable law or treaty (including future 89 | time extensions), (iii) in any current or future medium and for any number 90 | of copies, and (iv) for any purpose whatsoever, including without 91 | limitation commercial, advertising or promotional purposes (the 92 | "License"). The License shall be deemed effective as of the date CC0 was 93 | applied by Affirmer to the Work. Should any part of the License for any 94 | reason be judged legally invalid or ineffective under applicable law, such 95 | partial invalidity or ineffectiveness shall not invalidate the remainder 96 | of the License, and in such case Affirmer hereby affirms that he or she 97 | will not (i) exercise any of his or her remaining Copyright and Related 98 | Rights in the Work or (ii) assert any associated claims and causes of 99 | action with respect to the Work, in either case contrary to Affirmer's 100 | express Statement of Purpose. 101 | 102 | 4. Limitations and Disclaimers. 103 | 104 | a. No trademark or patent rights held by Affirmer are waived, abandoned, 105 | surrendered, licensed or otherwise affected by this document. 106 | b. Affirmer offers the Work as-is and makes no representations or 107 | warranties of any kind concerning the Work, express, implied, 108 | statutory or otherwise, including without limitation warranties of 109 | title, merchantability, fitness for a particular purpose, non 110 | infringement, or the absence of latent or other defects, accuracy, or 111 | the present or absence of errors, whether or not discoverable, all to 112 | the greatest extent permissible under applicable law. 113 | c. Affirmer disclaims responsibility for clearing rights of other persons 114 | that may apply to the Work or any use thereof, including without 115 | limitation any person's Copyright and Related Rights in the Work. 116 | Further, Affirmer disclaims responsibility for obtaining any necessary 117 | consents, permissions or other rights required for any use of the 118 | Work. 119 | d. Affirmer understands and acknowledges that Creative Commons is not a 120 | party to this document and has no duty or obligation with respect to 121 | this CC0 or use of the Work. -------------------------------------------------------------------------------- /SFR_test.py: -------------------------------------------------------------------------------- 1 | import matplotlib.pyplot as plt 2 | import numpy as np 3 | import os.path 4 | import time 5 | 6 | import SFR 7 | import slanted_edge_target 8 | import utils 9 | 10 | 11 | def test(): 12 | # A kind of "verbosity" for plots, higher value means more plots: 13 | show_plots = 8 # setting this to 0 will result in no plots and much faster execution 14 | print(f"show_plots: {show_plots}") 15 | 16 | n = 100 # sample ROI size is n x n pixels 17 | 18 | n_well_fs = 10000 # simulated no. of electrons at full scale for the noise calculation 19 | output_fs = 1.0 # image sensor output at full scale 20 | 21 | def add_noise(sample_edge): 22 | np.random.seed(0) # make the noise deterministic in order to facilitate comparisons and debugging 23 | return np.random.poisson(sample_edge / output_fs * n_well_fs) / n_well_fs 24 | 25 | # -------------------------------------------------------------------- 26 | # Create a curved edge image with a custom esf for testing 27 | esf = slanted_edge_target.InterpolateESF([-0.5, 0.5], 28 | [0.0, 1.0]).f # ideal edge esf for pixels with 100% fill factor 29 | 30 | # arrays of positions and corresponding esf values 31 | x, edge_lsf_pixel = slanted_edge_target.calc_custom_esf(sigma=0.3, pixel_fill_factor=1.0, show_plots=show_plots) 32 | # a more realistic (custom) esf 33 | esf = slanted_edge_target.InterpolateESF(x, edge_lsf_pixel).f 34 | 35 | image_float, _ = slanted_edge_target.make_slanted_curved_edge((n, n), curvature=0.001, 36 | illum_gradient_angle=0.0, 37 | illum_gradient_magnitude=0.0, 38 | low_level=0.25, hi_level=0.70, esf=esf, angle=5.0) 39 | im = image_float 40 | 41 | # Display the image in 8 bit grayscale 42 | nbits = 8 43 | image_int = np.round((2 ** nbits - 1) * im.clip(0.0, 1.0)).astype(np.uint8) 44 | image_int = np.stack([image_int for i in range(3)], axis=2) 45 | 46 | # Save slanted edge ROI as an image file in the current directory 47 | current_dir = os.path.abspath(os.path.dirname(__file__)) 48 | save_path = os.path.join(current_dir, "slanted_edge_example.png") 49 | plt.imsave(save_path, image_int, vmin=0, vmax=255, cmap='gray') 50 | 51 | # -------------------------------------------------------------------- 52 | # (This is where you would load your own ROI image from file. Remember to 53 | # also remove gamma and to apply white balance if raw images are used from 54 | # an image sensor with a Bayer (color filter) pattern.) 55 | 56 | # Load slanted edge ROI image from from file 57 | im = plt.imread("slanted_edge_example.png") 58 | 59 | sample_edge = utils.relative_luminance(im) 60 | for simulate_noise in [False]: # [False, True]: 61 | sample = add_noise(sample_edge) if simulate_noise else sample_edge 62 | 63 | if show_plots >= 6: 64 | # display the image in 8 bit grayscale 65 | nbits = 8 66 | image_int = np.round((2 ** nbits - 1) * sample.clip(0.0, 1.0)).astype(np.uint8) 67 | # plt.ishow and plt.imsave with cmap='gray' doesn't interpolate properly(!), so we 68 | # make an explicit grayscale sRGB image instead 69 | image_int = np.stack([image_int for i in range(3)], axis=2) 70 | plt.figure() 71 | plt.imshow(image_int) 72 | # plt.imshow(image_int, cmap='gray', vmin=0, vmax=255) 73 | 74 | print(" ") 75 | sfr_linear = SFR.SFR(quadratic_fit=False, verbose=True, show_plots=show_plots) 76 | sfr = SFR.SFR(verbose=True, show_plots=show_plots) 77 | mtf_linear, status_linear = sfr_linear.calc_sfr(sample) 78 | mtf, status = sfr.calc_sfr(sample) 79 | print(f"\nNow do the exact same two function calls, but without diagnostic" 80 | f" plots, to get the true execution speed of the SFR calculation" 81 | f" from the {n:d} x {n:d} pixel ROI image:") 82 | # This is how you would call the function in an automated script. 83 | # Remember that you can comment out the "@execution_timer" 84 | # decorators in the SFR.py module and skip verbosity (default is False): 85 | sfr_linear = SFR.SFR(quadratic_fit=False) 86 | sfr = SFR.SFR() 87 | 88 | def meas_execution_time(func, roi_image, n_repeats=50): 89 | t0 = time.time() 90 | for i in range(n_repeats): 91 | func(roi_image) 92 | t1 = (time.time() - t0) / n_repeats 93 | return t1, t1 / roi_image.size 94 | 95 | t, t_per_pixel = meas_execution_time(sfr_linear.calc_sfr, sample, n_repeats=50) 96 | print(f"SFR.calc_sfr() with straight edge fitting took {t:.3f} s to execute, " 97 | f"or {t_per_pixel / 1e-6:.1f} us per pixel.") 98 | 99 | t, t_per_pixel = meas_execution_time(sfr.calc_sfr, sample, n_repeats=50) 100 | print(f"SFR.calc_sfr() with curved edge fitting took {t:.3f} s to execute, " 101 | f"or {t_per_pixel / 1e-6:.1f} us per pixel.") 102 | 103 | print(" ") 104 | 105 | if show_plots >= 1: 106 | plt.figure() 107 | plt.plot(mtf_linear[:, 0], mtf_linear[:, 1], '.-', label="linear fit to edge") 108 | plt.plot(mtf[:, 0], mtf[:, 1], '.-', label="quadratic fit to edge") 109 | f = np.arange(0.0, 2.0, 0.01) 110 | mtf_sinc = np.abs(np.sinc(f)) 111 | plt.plot(f, mtf_sinc, 'k-', label="sinc") 112 | plt.xlim(0, 2.0) 113 | plt.ylim(0, 1.2) 114 | textstr = f"Edge angle: {status_linear['angle']:.1f}°" 115 | props = dict(facecolor='w', alpha=0.5) 116 | ax = plt.gca() 117 | plt.text(0.65, 0.60, textstr, transform=ax.transAxes, 118 | verticalalignment='top', bbox=props) 119 | plt.grid() 120 | shape = f'{sample_edge.shape[1]:d}x{sample_edge.shape[0]:d} px' 121 | if simulate_noise and n_well_fs > 0: 122 | n_well_lo, n_well_hi = np.unique(sample_edge)[[0, -1]] * n_well_fs 123 | snr_lo, snr_hi = np.sqrt([n_well_lo, n_well_hi]) 124 | noise = f' SNR={snr_lo:.0f} (dark) and SNR={snr_hi:.0f} (bright)' 125 | else: 126 | noise = 'out noise' 127 | angle = status_linear['angle'] 128 | plt.title(f'SFR from {shape:s} curved slanted edge\nwith{noise:s}, edge angle={angle:.1f}°') 129 | plt.ylabel('MTF') 130 | plt.xlabel('Spatial frequency (cycles/pixel)') 131 | plt.legend(loc='best') 132 | 133 | # _______________________________________________________________________________ 134 | # Test with ideal slanted edge, result should be very similar to a sinc function (fourier transform of 135 | # a square aperture representing the image sensor pixel) (black curve) 136 | if True: 137 | ideal_edge = slanted_edge_target.make_ideal_slanted_edge((n, n), angle=85.0) 138 | 139 | for simulate_noise in [False, True]: 140 | sample = add_noise(ideal_edge) if simulate_noise else ideal_edge 141 | 142 | if show_plots >= 6: 143 | # display the image in 8 bit grayscale 144 | nbits = 8 145 | image_int = np.round((2 ** nbits - 1) * sample.clip(0.0, 1.0)).astype(np.uint8) 146 | # plt.imshow and plt.imsave with cmap='gray' doesn't interpolate properly(!), so we 147 | # make an explicit grayscale sRGB image instead 148 | image_int = np.stack([image_int for i in range(3)], axis=2) 149 | plt.figure() 150 | plt.imshow(image_int) 151 | 152 | mtf_list = [] 153 | oversampling_list = [4, 6, 8] 154 | for oversampling in oversampling_list: 155 | sfr.set_oversampling(oversampling) 156 | mtf, status = sfr.calc_sfr(sample) 157 | mtf_list.append(mtf) 158 | 159 | if show_plots: 160 | plt.figure() 161 | for j, oversampling in zip(range(len(mtf_list)), oversampling_list): 162 | plt.plot(mtf_list[j][:, 0], mtf_list[j][:, 1], '.-', label=f"oversampling: {oversampling:2d}") 163 | f = np.arange(0.0, 2.0, 0.01) 164 | mtf_sinc = np.abs(np.sinc(f)) 165 | plt.plot(f, mtf_sinc, 'k-', label="sinc") 166 | plt.xlim(0, 2.0) 167 | plt.ylim(0, 1.2) 168 | textstr = f"Edge angle: {status['angle']:.1f}°" 169 | props = dict(facecolor='w', alpha=0.5) 170 | ax = plt.gca() 171 | plt.text(0.65, 0.60, textstr, transform=ax.transAxes, 172 | verticalalignment='top', bbox=props) 173 | plt.grid() 174 | 175 | plt.ylabel('MTF') 176 | plt.xlabel('Spatial frequency (cycles/pixel)') 177 | shape = f'{ideal_edge.shape[1]:d}x{ideal_edge.shape[0]:d} px' 178 | if simulate_noise and n_well_fs > 0: 179 | n_well_lo, n_well_hi = np.unique(ideal_edge)[[0, -1]] * n_well_fs 180 | snr_lo, snr_hi = np.sqrt([n_well_lo, n_well_hi]) 181 | noise = f' SNR(dark) = {snr_lo:.0f} and SNR(bright) = {snr_hi:.0f}' 182 | else: 183 | noise = 'out noise' 184 | plt.title(f'SFR from {shape:s} ideal slanted edge\nwith{noise:s}') 185 | plt.legend(loc='best') 186 | 187 | 188 | if __name__ == "__main__": 189 | test() 190 | plt.show() 191 | -------------------------------------------------------------------------------- /selection_tools.py: -------------------------------------------------------------------------------- 1 | import os.path 2 | 3 | import numpy as np 4 | import matplotlib.pyplot as plt 5 | import matplotlib.patches 6 | import json 7 | 8 | 9 | class RectXY: 10 | def __init__(self, fig, ax, width=20, height=20, filepath='test.png'): 11 | self.fixed_color = 'r' 12 | self.active_color = 'g' 13 | self.fig = fig 14 | self.ax = ax 15 | self.width = width 16 | self.height = height 17 | self.list_of_rects = [] 18 | self._fixed = False 19 | self.current_rect_index = None 20 | self.renderer = fig.canvas.get_renderer() 21 | self.linewidth = 1.0 22 | self.roi_data_filepath = filepath + '.json' 23 | self._load_roi_data() 24 | self.size_change_delta = 10 25 | 26 | def _load_roi_data(self): 27 | self.list_of_rects = [] 28 | if not os.path.exists(self.roi_data_filepath): 29 | return 30 | with open(self.roi_data_filepath, 'r') as f: 31 | data = json.load(f) 32 | for d in data: 33 | x, y = d['x'], d['y'] 34 | height = d['height'] 35 | width = d['width'] 36 | self._add_rect(x, y, width=width, height=height, fixed=True) 37 | if len(data) > 0: 38 | self.height = height 39 | self.width = width 40 | 41 | def populate_roi_data(self, roi_data): 42 | self.list_of_rects = [] 43 | if len(roi_data) == 0: 44 | return 45 | for d in roi_data: 46 | height = d['height'] 47 | width = d['width'] 48 | x_c, y_c = d['x_center'], d['y_center'] 49 | x, y = self._limit_movement(x_c, y_c, width, height) 50 | self._add_rect(x, y, width=width, height=height, fixed=True) 51 | 52 | self.height = height 53 | self.width = width 54 | 55 | def _add_rect(self, x0, y0, width=None, height=None, fixed=True): 56 | color = self.fixed_color if fixed else self.active_color 57 | if None in (width, height): 58 | width, height = self.width, self.height 59 | rect = matplotlib.patches.Rectangle((x0, y0), width, height, 60 | fill=False, linewidth=self.linewidth, color=color) 61 | self.list_of_rects.append(rect) 62 | self.ax.add_patch(rect) 63 | self.current_rect_index = len(self.list_of_rects) - 1 64 | self._fixed = fixed 65 | 66 | def _limit_movement(self, x, y, width, height): 67 | x, y = int(x), int(y) 68 | y_min, y_max = self.ax.axes.viewLim.p0[0] + 1, self.ax.axes.viewLim.p0[1] - height 69 | x_min, x_max = self.ax.axes.viewLim.p1[1] + 1, self.ax.axes.viewLim.p1[0] - width 70 | x_rect = int(np.clip(x - width // 2, a_min=x_min, a_max=x_max)) 71 | y_rect = int(np.clip(y - height // 2, a_min=y_min, a_max=y_max)) 72 | return x_rect, y_rect 73 | 74 | def move(self, event, color=None): 75 | color = self.active_color if color is None else color 76 | x, y = event.xdata, event.ydata 77 | if None in (x, y) or self._fixed or len(self.list_of_rects) == 0 or self.current_rect_index is None: 78 | return 79 | 80 | current_rect = self.list_of_rects[self.current_rect_index] 81 | current_rect.set_color(color) # set active color 82 | width, height = current_rect.get_width(), current_rect.get_height() 83 | current_rect.xy = self._limit_movement(x, y, width, height) 84 | self.fig.canvas.draw_idle() # update canvas with new rectangle position 85 | 86 | def fix_or_release(self, event): 87 | if len(self.list_of_rects) == 0: 88 | return 89 | 90 | if not self._fixed and self.current_rect_index is not None: 91 | current_rect = self.list_of_rects[self.current_rect_index] 92 | 93 | # Fix box in position 94 | self._fixed = True 95 | current_rect.set_color(self.fixed_color) 96 | self.fig.canvas.draw_idle() 97 | return 98 | 99 | # Release box only if the pointer within the box bounds 100 | x, y = int(event.xdata), int(event.ydata) 101 | for i, rect in enumerate(self.list_of_rects): # loop through all rectangles 102 | if self._within_box(x, y, rect): 103 | self._fixed = False 104 | self.current_rect_index = i 105 | break 106 | self.move(event, color=self.active_color) # update rectangle position and set the new color 107 | 108 | def _within_box(self, x, y, rect, center_fraction=0.2): 109 | xb, yb = self._box_center(rect) 110 | width, height = rect.get_width(), rect.get_height() 111 | x_within = (xb - width * center_fraction) < x < (xb + width * center_fraction) 112 | y_within = (yb - height * center_fraction) < y < (yb + height * center_fraction) 113 | return x_within and y_within 114 | 115 | def _box_center(self, rect): 116 | x0, y0 = rect.xy 117 | xb = x0 + rect.get_width() // 2 118 | yb = y0 + rect.get_height() // 2 # center coordinates of box 119 | return xb, yb 120 | 121 | def insert_or_delete_rectangle(self, event): 122 | delete_rectangle = not self._fixed and len(self.list_of_rects) > 0 and self.current_rect_index is not None 123 | if delete_rectangle: 124 | self.list_of_rects[self.current_rect_index].set_visible(False) 125 | self.list_of_rects[self.current_rect_index].remove() # remove rectangle from axes 126 | self.list_of_rects.pop(self.current_rect_index) # remove from internal list 127 | self.current_rect_index = None 128 | self.fig.canvas.draw_idle() 129 | return 130 | 131 | # Insert rectangle 132 | x, y = event.xdata, event.ydata 133 | if None in (x, y): 134 | return # avoid trying to insert rectangle when pointer is outside active area 135 | xr, yr = self._limit_movement(x, y, self.width, self.height) 136 | self._add_rect(xr, yr, fixed=False) 137 | self.fig.canvas.draw_idle() 138 | 139 | def _convert_to_dict(self): 140 | list_of_dicts = [] 141 | for rect in self.list_of_rects: 142 | x, y = rect.get_xy() 143 | height = rect.get_height() 144 | width = rect.get_width() 145 | list_of_dicts.append({'x': x, 'y': y, 'height': height, 'width': width}) 146 | return list_of_dicts 147 | 148 | def save_roi_data_and_close(self): 149 | roi_dict = self._convert_to_dict() 150 | with open(self.roi_data_filepath, 'w') as f: 151 | json.dump(roi_dict, f, indent=4) 152 | print(json.dumps(roi_dict, indent=4)) 153 | plt.close(self.fig) 154 | 155 | def change_roi_size(self, width_change, height_change): 156 | current_rect = self.list_of_rects[self.current_rect_index] 157 | x, y = current_rect.xy 158 | width, height = current_rect.get_width(), current_rect.get_height() 159 | width = max(width + width_change, 0) 160 | height = max(height + height_change, 0) 161 | x = int(x - width_change / 2) 162 | y = int(y - height_change / 2) 163 | current_rect.set_width(width) 164 | current_rect.set_height(height) 165 | current_rect.set_xy((x, y)) 166 | self.fig.canvas.draw_idle() 167 | 168 | def json_path(self): 169 | return self.roi_data_filepath 170 | 171 | def keypress(self, event): 172 | if event.key == ' ': 173 | self.insert_or_delete_rectangle(event) 174 | if event.key == 'e': 175 | self.save_roi_data_and_close() 176 | if event.key == '8': 177 | self.change_roi_size(0, self.size_change_delta) 178 | if event.key == '2': 179 | self.change_roi_size(0, -self.size_change_delta) 180 | if event.key == '4': 181 | self.change_roi_size(-self.size_change_delta, 0) 182 | if event.key == '6': 183 | self.change_roi_size(self.size_change_delta, 0) 184 | 185 | 186 | def read_and_crop(im, json_path): 187 | if not os.path.exists(json_path): 188 | return [im] 189 | with open(json_path, 'r') as f: 190 | roi_data = json.load(f) 191 | roi_list = [] 192 | if len(roi_data) > 0: 193 | for r in roi_data: 194 | roi_list.append(im[r['y']:r['y'] + r['height'], r['x']:r['x'] + r['width']]) 195 | return roi_list 196 | else: 197 | return [im] 198 | 199 | 200 | def crop(im, roi_data): 201 | roi_list = [] 202 | if len(roi_data) > 0: 203 | for r in roi_data: 204 | x_c, y_c = r['x_center'], r['y_center'] 205 | width, height = r['width'], r['height'] 206 | y_min, y_max = 0 + 1, im.shape[0] - height 207 | x_min, x_max = 0 + 1, im.shape[1] - width 208 | x_r = int(np.clip(x_c - width // 2, a_min=x_min, a_max=x_max)) 209 | y_r = int(np.clip(y_c - height // 2, a_min=y_min, a_max=y_max)) 210 | roi_list.append(im[y_r:y_r + height, x_r:x_r + width]) 211 | return roi_list 212 | else: 213 | return [im] 214 | 215 | 216 | def test(): 217 | import selection_tools 218 | 219 | im_file = 'televinken.jpg' 220 | image = plt.imread(im_file) 221 | image = image[:, :, :3] 222 | 223 | # Create a figure for plotting the image 224 | fig, ax = plt.subplots() 225 | ax.imshow(image, cmap='gray') 226 | ax.set_title('Insert/delete ROI:s with space bar, \nfix/release ROI:s with left mouse clock, \n' 227 | 'save ROIs and close window with "e"', fontsize=9) 228 | 229 | print(f'image.shape: {image.shape}') 230 | 231 | r = selection_tools.RectXY(fig, ax, filepath=im_file, width=80, height=80) 232 | fig.canvas.mpl_connect('motion_notify_event', r.move) 233 | fig.canvas.mpl_connect('button_press_event', r.fix_or_release) 234 | fig.canvas.mpl_connect('key_release_event', r.keypress) 235 | 236 | plt.show() 237 | 238 | json_path = im_file + '.json' 239 | 240 | with open(json_path, 'r') as f: 241 | roi_data = json.load(f) 242 | im_rois = selection_tools.read_and_crop(image, json_path) 243 | for im_roi, r in zip(im_rois, roi_data): 244 | plt.figure() 245 | title = f'x, y = {r["x"]}:{r["x"] + r["width"]}, {r["y"]}:{r["y"] + r["height"]}' 246 | plt.imshow(im_roi) 247 | plt.title(title) 248 | plt.show() 249 | 250 | 251 | if __name__ == '__main__': 252 | test() 253 | -------------------------------------------------------------------------------- /slanted_edge_target.py: -------------------------------------------------------------------------------- 1 | """ 2 | slanted_edge_target.m - Create synthetic slanted edges with varying levels of sharpness and imperfections 3 | Written in 2022 by Carl Asplund carl.asplund@eclipseoptics.com 4 | 5 | To the extent possible under law, the author(s) have dedicated all copyright 6 | and related and neighboring rights to this software to the public domain worldwide. 7 | This software is distributed without any warranty. 8 | You should have received a copy of the CC0 Public Domain Dedication along with 9 | this software. If not, see . 10 | 11 | 12 | make_ideal_slanted_edge() 13 | Make an ideal slanted edge passing through the center of the image, with 14 | maximum theoretical sharpness for 100% fill factor pixels. 15 | 16 | make_slanted_curved_edge() 17 | Make a slanted edge passing through the center of the image, with support for 18 | edge curvature, uneven illumination, and arbitrary edge profiles. 19 | """ 20 | 21 | import numpy as np 22 | 23 | import SFR 24 | import utils 25 | 26 | 27 | def make_ideal_slanted_edge(image_shape=(100, 100), angle=5.0, low_level=0.20, hi_level=0.80, 28 | pixel_fill_factor=1.0): 29 | """ 30 | Make an ideal slanted edge passing through the center of the image, with maximum theoretical sharpness 31 | for 100% fill factor pixels 32 | 33 | Parameters: 34 | image_shape: tuple of int 35 | (height, width) in units of pixels 36 | angle: float 37 | edge angle relative to the vertical axis (degrees), default value is 5° 38 | low_level: float 39 | gray level (arbitrary units) at the dark side of the edge 40 | hi_level: float 41 | gray level (arbitrary units) at the bright side of the edge 42 | pixel_fill_factor: float 43 | pixel fill factor, 1.0 gives ideal edge, <1.0 gives aliasing, >1.0 gives blurred edge 44 | Returns: 45 | slanted edge image as a numpy array of float 46 | """ 47 | 48 | height, width = image_shape 49 | xx, yy = np.meshgrid(range(0, width), range(0, height)) 50 | x_midpoint = width / 2.0 - 0.5 51 | y_midpoint = height / 2.0 - 0.5 52 | 53 | # Calculate distance to edge (<0 means pixel is to the left of the edge, 54 | # >0 means pixel is to the right) 55 | dist_edge = np.cos(-angle * np.pi / 180) * (xx - x_midpoint) + -np.sin(-angle * np.pi / 180) * (yy - y_midpoint) 56 | 57 | dist_edge /= np.sqrt(pixel_fill_factor) 58 | 59 | return low_level + (hi_level - low_level) * (0.5 + dist_edge.clip(-0.5, 0.5)) 60 | 61 | 62 | def conv(a, b): 63 | # Perform convolution of arrays a and b in a way that preserves the 64 | # values at the boundaries. 65 | # The output has the same size as a, and it is assumed that len(a) >= len(b) 66 | pad_width = len(b) 67 | a_padded = np.pad(a, pad_width, mode='edge') 68 | return np.convolve(a_padded, b, mode='same')[pad_width:-pad_width] / np.sum(b) 69 | 70 | 71 | class InterpolateESF: 72 | def __init__(self, xp, yp): 73 | self.xp = xp 74 | self.yp = yp 75 | 76 | def f(self, x): 77 | # linear interpolation 78 | return np.interp(x, self.xp, self.yp, left=0.0, right=1.0) 79 | 80 | 81 | def make_slanted_curved_edge(image_shape=(100, 100), angle=5.0, curvature=0.001, 82 | low_level=0.25, hi_level=0.85, black_lvl=0.05, 83 | illum_gradient_angle=75.0, 84 | illum_gradient_magnitude=+0.05, esf=InterpolateESF([-0.5, 0.5], [0.0, 1.0]).f): 85 | """ 86 | Make a slanted edge passing through the center of the image, with support for 87 | edge curvature, uneven illumination, and custom edge spread functions. 88 | 89 | Parameters: 90 | image_shape: tuple of int 91 | (height, width) in units of pixels 92 | angle: float 93 | c.w. angle (degrees) of slanted edge relative to vertical axis, valid range is [-90.0, 90.0], 94 | default value is 5° 95 | curvature: float 96 | curvature k(y) = f''(y) / (1 + f'(y)^2)^(3/2), where x = f(y) is a 2nd order polynomial describing the edge 97 | low_level: float 98 | gray level (arbitrary units) at dark side of the edge 99 | hi_level: float 100 | gray level (arbitrary units) at bright side of the edge 101 | black_lvl: float 102 | gray level corresponding got zero illumination (a.k.a. pedestal) 103 | illum_gradient_angle: float 104 | illumination gradient direction (degrees), 0 is downward, 90 is left, 180 is up 105 | illum_gradient_magnitude: float 106 | illumination gradient magnitude, relative illumination change between center of image and edge, can be 107 | either negative of positive 108 | esf: function 109 | function that returns the edge spread profile as function of distance (in units of 110 | pixels), with 0.0 at minus infinity, and 1.0 at positive infinity 111 | Returns: 112 | im: numpy array of floats 113 | slanted edge image (2-d) 114 | dist_edge: numpy array of floats 115 | distance to the edge from each pixel (2-d) 116 | """ 117 | 118 | angle = np.clip(-angle, a_min=-90.0, a_max=90.0) 119 | 120 | inv_c = 1.0 121 | step_fctr = 0.0 122 | angle_offset = 0.0 123 | 124 | # The algorithms for the edge distance are made with near-vertical edges 125 | # in mind. Temporarily rotate the image 90° if the edge is more than 45° 126 | # from the vertical axis. 127 | if np.abs(angle) > 45.0: 128 | angle_offset = -90.0 129 | image_shape = image_shape[::-1] # width -> height, and height -> width 130 | if angle > 45.0: 131 | step_fctr = -1.0 132 | inv_c = -1.0 133 | 134 | def midpoint(image_shape): 135 | return image_shape[0] / 2.0 - 0.5, image_shape[1] / 2.0 - 0.5 136 | 137 | y_midpoint, x_midpoint = midpoint(image_shape) 138 | 139 | # Describe the curved edge shape as a 2nd order polynomial 140 | slope = SFR.slope_from_angle(angle + angle_offset) 141 | p = SFR.SFR().polynomial_from_midpoint_slope_and_curvature(y_midpoint, x_midpoint, slope, curvature * inv_c) 142 | 143 | # Calculate distance to edge (<0 means pixel is to the left of the edge, 144 | # >0 means pixel is to the right) 145 | dist_edge = SFR.SFR(quadratic_fit=True).calc_distance(image_shape, p) 146 | 147 | # Reverse step direction if edge angle is in the lower two quadrants (between 148 | # 90 and 270 degrees) 149 | step_dir = -1 if np.cos(np.deg2rad(angle + step_fctr * angle_offset)) < 0 else 1 150 | 151 | # Assign a gray value from the supplied ESF function to each pixel based 152 | # on its distance from the edge 153 | im = low_level + (hi_level - low_level) * esf(step_dir * dist_edge) 154 | 155 | # If previously rotated, reverse rotation of image back to the original orientation 156 | if np.abs(angle) > 45.0: 157 | im = im.T[:, ::-1] # rotate 90° right by transposing and mirroring 158 | image_shape = image_shape[::-1] # width -> height, and height -> width 159 | y_midpoint, x_midpoint = midpoint(image_shape) 160 | 161 | # Apply illumination gradient 162 | if illum_gradient_magnitude != 0.0: 163 | slope_gradient = SFR.slope_from_angle(illum_gradient_angle - 90.0) 164 | p = SFR.SFR().polynomial_from_midpoint_slope_and_curvature(y_midpoint, x_midpoint, slope_gradient, 0.0) 165 | illum_gradient_dist = SFR.SFR(quadratic_fit=False).calc_distance(image_shape, p) 166 | illum_gradient = 1 + illum_gradient_dist / (image_shape[0] / 2) * illum_gradient_magnitude 167 | im = np.clip((im - black_lvl) * illum_gradient, a_min=0.0, a_max=None) + black_lvl 168 | 169 | return im, dist_edge 170 | 171 | 172 | def calc_custom_esf(x_length=5.0, x_step=0.01, x_edge=0.0, pixel_fill_factor=1.00, 173 | pixel_pitch=1.0, sigma=0.2, show_plots=0): 174 | # Create a custom edge spread function (ESF) by convolution of three functions: 175 | # - an ideal edge, 176 | # - a line spread function (LSF) representing the optics transfer function, 177 | # - a pixel aperture 178 | # This is intended as an example. In e.g. situations where the image 179 | # sensor has noticeable pixel crosstalk, the LSF (or MTF) of the pixel 180 | # itself should be used, instead of a simple aperture box. 181 | 182 | def gauss(x, h=0.0, a=1.0, x0=0.0, sigma=1.0): 183 | return h + a * np.exp(-(x - x0) ** 2 / (2 * sigma ** 2)) 184 | 185 | w = pixel_pitch / 2 * np.sqrt(pixel_fill_factor) # aperture width 186 | 187 | # 1-d position vector 188 | x = np.arange(-x_length / 2, x_length / 2, x_step) 189 | 190 | # ideal edge (step function) 191 | edge = np.heaviside(x - x_edge, 0.5) 192 | 193 | # optics line spread function (LSF) 194 | lsf = gauss(x, x0=x_edge, sigma=sigma) 195 | 196 | # pixel aperture (box filter) 197 | pixel = np.heaviside(x - (x_edge - w), 0.5) * np.heaviside((x_edge + w) - x, 0.5) 198 | 199 | # Convolve edge with lsf and pixel 200 | edge_lsf = conv(edge, lsf) 201 | edge_lsf_pixel = conv(edge_lsf, pixel) 202 | 203 | if show_plots >= 5: 204 | import matplotlib.pyplot as plt 205 | plt.figure() 206 | plt.plot(x, edge, label='edge') 207 | plt.plot(x, lsf, label='LSF from optics') 208 | plt.plot(x, edge_lsf, label='ESF from optics') 209 | plt.plot(x, pixel, label='pixel aperture') 210 | plt.plot(x, edge_lsf_pixel, '--', label='ESF sampled by pixels') 211 | plt.grid(linestyle='--') 212 | plt.xlabel('Position (pixel pitch)') 213 | plt.ylabel('Signal value') 214 | plt.legend() 215 | return x, edge_lsf_pixel 216 | 217 | 218 | if __name__ == '__main__': 219 | import os 220 | import matplotlib.pyplot as plt 221 | 222 | 223 | def gray2rgb(image, pedestal, gain_r, gain_g, gain_b, cfa_rgb=np.array([[0, 1], [1, 2]])): 224 | im_rgb = np.zeros_like(image).astype(float) 225 | im_rgb[0::2, 0::2] = image[0::2, 0::2] * gain_r + pedestal 226 | im_rgb[0::2, 1::2] = image[0::2, 1::2] * gain_g + pedestal 227 | im_rgb[1::2, 0::2] = image[1::2, 0::2] * gain_g + pedestal 228 | im_rgb[1::2, 1::2] = image[1::2, 1::2] * gain_b + pedestal 229 | return im_rgb 230 | 231 | 232 | pedestal = 19.0 233 | gain_r, gain_g, gain_b = 1.1, 1.8, 1.3 234 | seed = 1 235 | s = 40 236 | sfr = SFR.SFR(show_plots=0, quadratic_fit=False) 237 | for pixel_fill_factor in [4.0, 1.0, 0.06]: 238 | # Create an ideal slanted edge image with default settings 239 | image_float = make_ideal_slanted_edge(image_shape=(100, 100), low_level=0.20, 240 | pixel_fill_factor=pixel_fill_factor) 241 | 242 | # Display the image in 8 bit grayscale 243 | nbits = 8 244 | image_int = np.round((2 ** nbits - 1) * image_float.clip(0.0, 1.0)).astype(np.uint8) 245 | 246 | # TODO: plt.imshow() and plt.imsave() with cmap='gray' doesn't interpolate properly(!), 247 | # leaving histogram gaps and neighboring peaks, so we make an explicitly grayscale MxNx3 RGB image instead 248 | image_int = np.stack([image_int for i in range(3)], axis=2) 249 | plt.figure() 250 | plt.imshow(image_int) 251 | plt.title(f'Ideal slanted edge, fill factor={pixel_fill_factor}') 252 | 253 | # Save as an image file in the current directory 254 | current_dir = os.path.abspath(os.path.dirname(__file__)) 255 | save_path = os.path.join(current_dir, "ideal_slanted_edge_example.png") 256 | plt.imsave(save_path, image_int, vmin=0, vmax=255, cmap='gray') 257 | 258 | # Add noise 259 | im_gray_input = image_int[:, :, 0].astype(float) 260 | np.random.seed(seed) 261 | # im_gray_input = np.random.poisson(im_gray_input * 1e1) / 1e1 262 | im_gray_input = im_gray_input + 0.3 * np.sqrt(im_gray_input) * np.random.normal( 263 | size=im_gray_input.shape) 264 | plt.figure() 265 | plt.title('im_gray_input') 266 | plt.imshow(im_gray_input, cmap='gray', vmin=0.0, vmax=255.0) 267 | im_save = np.stack([im_gray_input.clip(0.0, 255.0).astype(np.uint8) for i in range(3)], axis=2) 268 | plt.imsave(os.path.join(current_dir, "im_gray_input.png"), im_save, vmin=0, vmax=255, cmap='gray') 269 | 270 | # Test RGB decoding / white balancing 271 | im_rgb = gray2rgb(im_gray_input, pedestal, gain_r, gain_g, gain_b) # simulate RGB image 272 | plt.figure() 273 | plt.title('im_rgb_input') 274 | plt.imshow(im_rgb, cmap='gray') 275 | 276 | mtf, status = sfr.calc_sfr(im_rgb) 277 | 278 | # Select dark and light flat sections on either side of the edge 279 | im_rgb_crop_dark = im_rgb[0:s, 0:s] 280 | im_rgb_crop_light = im_rgb[0:s, -(s + s % 2):-1] 281 | # White balance image (normalized to G channel) and estimate pedestal 282 | im_gray, pedestal_estim, _ = utils.rgb2gray(im_rgb, im_rgb_crop_dark, im_rgb_crop_light) 283 | plt.figure() 284 | plt.title('im gray whitebalanced from RGB, normalized, pedestal removed') 285 | im_gray = (im_gray - pedestal_estim) / gain_g 286 | plt.imshow(im_gray, cmap='gray', vmin=0.0, vmax=255.0) 287 | im_save2 = np.stack([im_gray.clip(0.0, 255.0).astype(np.uint8) for i in range(3)], axis=2) 288 | plt.imsave(os.path.join(current_dir, "im_gray_whitebalanced.png"), im_save2, vmin=0, vmax=255, cmap='gray') 289 | 290 | im_diff = np.unique(im_save2.astype(int) - im_save.astype(int)) 291 | 292 | im_ratio = im_gray / im_gray_input 293 | size_str = f'white balancing ROIs: {s} x {s} pixels' 294 | pedestal_str = f'estimated pedestal: {pedestal_estim:.1f}, true pedestal: {pedestal:.1f}' 295 | errors_str = f'errors in whitebalanced edge ROI image, compared to gray scale original: {im_diff}' 296 | print(f'{size_str}; {pedestal_str}; {errors_str}') 297 | 298 | # -------------------------------------------------------------------------------- 299 | # Create a curved edge image with a custom esf 300 | esf = InterpolateESF([-0.5, 0.5], [0.0, 1.0]).f # ideal edge esf for pixels with 100% fill factor 301 | 302 | # arrays of positions and corresponding esf values 303 | x, edge_lsf_pixel = calc_custom_esf(sigma=0.8, pixel_fill_factor=1.0, show_plots=5) 304 | 305 | esf = InterpolateESF(x, edge_lsf_pixel).f # a more realistic (custom) esf 306 | 307 | if 0: 308 | for angle in range(-90, 90 + 1, 10): 309 | image_float, _ = make_slanted_curved_edge((80, 100), illum_gradient_angle=45.0, 310 | illum_gradient_magnitude=4 * 0.15, curvature=-2 * 0.001, 311 | low_level=0.25, hi_level=0.70, esf=esf, angle=angle) 312 | 313 | # Display the image in 8 bit grayscale 314 | nbits = 8 315 | image_int = np.round((2 ** nbits - 1) * image_float.clip(0.0, 1.0)).astype(np.uint8) 316 | image_int = np.stack([image_int for i in range(3)], axis=2) 317 | plt.figure() 318 | plt.title(f"angle: {angle:.1f}°") 319 | plt.imshow(image_int) 320 | 321 | # Save as an image file in the current directory 322 | current_dir = os.path.abspath(os.path.dirname(__file__)) 323 | save_path = os.path.join(current_dir, "slanted_edge_example.png") 324 | plt.imsave(save_path, image_int, vmin=0, vmax=255, cmap='gray') 325 | 326 | plt.show() 327 | print("Finished!") 328 | -------------------------------------------------------------------------------- /SFR.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | """ 3 | File SFR.m - Calculate spatial frequency response (SFR) for a slanted edge. 4 | Written in 2022 by Carl Asplund carl.asplund@eclipseoptics.com 5 | 6 | To the extent possible under law, the author(s) have dedicated all copyright 7 | and related and neighboring rights to this software to the public domain worldwide. 8 | This software is distributed without any warranty. 9 | You should have received a copy of the CC0 Public Domain Dedication along with 10 | this software. If not, see . 11 | 12 | 13 | Class SFR. 14 | 15 | This is the class for the spatial frequency calculation. The constructor takes these optional arguments: 16 | oversampling (integer), default is 4: 17 | oversampling used in the calculation of the ESF profile 18 | show_plots (integer), default is 0: 19 | a kind of "verbosity" for plots, "0" means no plots 20 | difference_scheme (string: 'backward', 'central'), default is 'central' 21 | differentiation scheme used in determining the edge position centroids and for calculating 22 | the line spread function (LSF) from the edge spread function (ESF) 23 | verbose (boolean), default is False 24 | if True, print messages with information about intermediate steps in the SFR calculation 25 | return_fig (boolean), default is False 26 | if True, the method calc_sfr() returns also a handle to a figure with the ESF profile 27 | quadratic_fit (boolean), default is True 28 | if True, fit a second order polynomial to the slanted edge, otherwise fit a straight edge 29 | 30 | Usage: 31 | import SFR # import this module (SFR.py) 32 | sfr = SFR.SFR() # set up an SFR object using the SFR() constructor 33 | mtf, status = sfr.calc_sfr(image) # use the calc_sfr() method to obtain the MTF results for the supplied image 34 | """ 35 | 36 | import matplotlib.pyplot as plt 37 | import numpy as np 38 | import scipy.signal 39 | 40 | # from execution_timer import execution_timer # (optional) timing module 41 | 42 | 43 | def angle_from_slope(slope): 44 | return np.rad2deg(np.arctan(slope)) 45 | 46 | 47 | def slope_from_angle(angle): 48 | return np.tan(np.deg2rad(angle)) 49 | 50 | 51 | class SFR: 52 | def __init__(self, oversampling=4, show_plots=0, difference_scheme='central', verbose=False, 53 | return_fig=False, quadratic_fit=True): 54 | self.oversampling = oversampling 55 | self.show_plots = show_plots 56 | self.difference_scheme = difference_scheme 57 | self.verbose = verbose 58 | self.return_fig = return_fig 59 | self.quadratic_fit = quadratic_fit 60 | self.lsf_centering_kernel_sz = 9 61 | self.win_width_factor = 1.5 62 | self.lsf_threshold = 0.10 63 | if self.difference_scheme == 'backward': 64 | self.diff_kernel = np.array([1.0, -1.0]) 65 | self.diff_offset = -0.5 66 | self.diff_ft = 4 # factor used in the correction of the numerical derivation 67 | elif self.difference_scheme == 'central': 68 | self.diff_kernel = np.array([0.5, 0.0, -0.5]) 69 | self.diff_offset = 0.0 70 | self.diff_ft = 2 71 | self.conv_kernel = 3 72 | self.win_width = 5 73 | 74 | def set_oversampling(self, oversampling): 75 | self.oversampling = oversampling 76 | 77 | def centroid(self, arr): 78 | height, width = arr.shape 79 | 80 | win = np.zeros(arr.shape) 81 | for i in range(height): 82 | win_c = np.argmax(np.abs(np.convolve(arr[i, :], np.ones(self.conv_kernel), 'same'))) 83 | win[i, win_c - self.win_width:win_c + self.win_width] = 1.0 84 | 85 | x, _ = np.meshgrid(np.arange(width), np.arange(height)) 86 | sum_arr = np.sum(arr * win, axis=1) 87 | sum_arr_x = np.sum(arr * win * x, axis=1) 88 | 89 | # By design, the following division will result in nan for any row that lack an 90 | # edge transition 91 | with np.errstate(divide='ignore', invalid='ignore'): 92 | return sum_arr_x / sum_arr # suppress divide-by-zero warnings 93 | 94 | def differentiate(self, arr): 95 | if len(arr.shape) == 2: 96 | # Use 2-d convolution, but with a one-dimensional (row-oriented) kernel 97 | out = scipy.signal.convolve2d(arr, [self.diff_kernel], 'same', 'symm') 98 | else: 99 | # Input is a one-dimensional array 100 | out = np.convolve(arr, self.diff_kernel, 'same') 101 | # The first element is not valid since there is no 'symm' option, 102 | # replace it with 0.0 (thereby maintaining the input array size) 103 | out[0] = 0.0 104 | return out 105 | 106 | def find_edge(self, centr, patch_shape, rotated): 107 | # Find 2nd and 1st order polynomials that best approximate the 108 | # edge shape given by the vector of LSF centroids supplied in "centr" 109 | # 110 | # input 111 | # centr: centroid location for each row 112 | # patch_shape: tuple with (height, width) info about the patch 113 | # output 114 | # pcoefs: 2nd order polynomial coefs from the least squares fit to the edge 115 | # [slope, offset]: polynomial coefs from the linear fit to the edge 116 | 117 | # Weed out positions in the vector of centroid values that 118 | # contain nan or inf. These positions represent rows that lack 119 | # an edge transition. Remove also the first and last values. 120 | idx = np.where(np.isfinite(centr))[0][1:-1] 121 | 122 | # Find the location and direction of the edge by fitting a line to the 123 | # centroids on the form x = y*slope + offset 124 | slope, offset = np.polyfit(idx, centr[idx], 1) 125 | 126 | # pcoefs contains quadratic polynomial coefficients for the x-coordinate 127 | # of the curved edge as a function of the y-coordinate: 128 | # x = pcoefs[0] * y**2 + pcoefs[1] * y + pcoefs[2] 129 | pcoefs = np.polyfit(idx, centr[idx], 2) 130 | 131 | if self.show_plots >= 5: 132 | self.verbose and print("showing plots!") 133 | fig, ax = plt.subplots() 134 | if rotated: 135 | ax.plot(idx, patch_shape[1] - centr[idx], '.k', label="centroids") 136 | ax.plot(idx, patch_shape[1] - np.polyval([slope, offset], idx), '-', label="linear fit") 137 | ax.plot(idx, patch_shape[1] - np.polyval(pcoefs, idx), '--', label="quadratic fit") 138 | ax.set_xlim([0, patch_shape[0]]) 139 | ax.set_ylim([0, patch_shape[1]]) 140 | else: 141 | ax.plot(centr[idx], idx, '.k', label="centroids") 142 | ax.plot(np.polyval([slope, offset], idx), idx, '-', label="linear fit") 143 | ax.plot(np.polyval(pcoefs, idx), idx, '--', label="quadratic fit") 144 | ax.set_xlim([0, patch_shape[1]]) 145 | ax.set_ylim([0, patch_shape[0]]) 146 | ax.set_aspect('equal', 'box') 147 | ax.legend(loc='best') 148 | ax.invert_yaxis() 149 | # plt.show() 150 | 151 | return pcoefs, slope, offset 152 | 153 | @staticmethod 154 | def midpoint_slope_and_curvature_from_polynomial(a, b, c, y0, y1): 155 | # Describe input 2nd degree polynomial f(y) = a*y**2 + b*y + c in 156 | # terms of midpoint, slope (at midpoint), and curvature (at midpoint) 157 | y_mid = (y1 + y0) / 2 158 | x_mid = a * y_mid ** 2 + b * y_mid + c 159 | # Calculated slope as first derivative of x = f(y) at y = y_mid 160 | slope = 2 * a * y_mid + b 161 | # Calculate the curvature as k(y) = f''(y) / (1 + f'(y)^2)^(3/2) 162 | curvature = 2 * a / (1 + slope ** 2) ** (3 / 2) 163 | return y_mid, x_mid, slope, curvature 164 | 165 | @staticmethod 166 | def polynomial_from_midpoint_slope_and_curvature(y_mid, x_mid, slope, curvature): 167 | # Calculate a 2nd degree polynomial x = f(y) = a*y**2 + b*y + c that passes 168 | # through the midpoint (x_mid, y_mid) with the given slope and curvature 169 | a = curvature * (1 + slope ** 2) ** (3 / 2) / 2 170 | b = slope - 2 * a * y_mid 171 | c = x_mid - a * y_mid ** 2 - b * y_mid 172 | return [a, b, c] 173 | 174 | @staticmethod 175 | def cubic_solver(a, b, c, d): 176 | # Solve the equation a*x**3 + b*x**2 + c*x + d = 0 for a 177 | # real-valued root x by Cardano's method 178 | # (https://en.wikipedia.org/wiki/Cubic_equation#Cardano's_formula) 179 | 180 | p = (3 * a * c - b ** 2) / (3 * a ** 2) 181 | q = (2 * b ** 3 - 9 * a * b * c + 27 * a ** 2 * d) / (27 * a ** 3) 182 | 183 | # A real root exists if 4 * p**3 + 27 * q**2 > 0 184 | sr = np.sqrt(q ** 2 / 4 + p ** 3 / 27) 185 | t = np.cbrt(-q / 2 + sr) + np.cbrt(-q / 2 - sr) 186 | x = t - b / (3 * a) 187 | return x 188 | 189 | @staticmethod 190 | def dot(a, b): 191 | return a[0] * b[0] + a[1] * b[1] 192 | 193 | # @execution_timer 194 | def calc_distance(self, data_shape, p): 195 | # Calculate the distance (with sign) from each point (x, y) in the 196 | # image patch "data" to the slanted edge described by the polynomial p. 197 | # It is assumed that the edge is approximately vertically orientated 198 | # (between -45° and 45° from the vertical direction). 199 | # Distances to points to the left of the edge are negative, and positive 200 | # to points to the right of the edge. 201 | x, y = np.meshgrid(range(data_shape[1]), range(data_shape[0])) 202 | 203 | self.verbose and print(f'quadratic fit: {str(self.quadratic_fit):s}') 204 | 205 | if not self.quadratic_fit or p[0] == 0.0: 206 | slope, offset = p[1], p[2] # use linear fit to edge 207 | a, b, c = 1, -slope, -offset 208 | a_b = np.sqrt(a ** 2 + b ** 2) 209 | 210 | # |ax+by+c| / |a_b| is the distance from (x,y) to the slanted edge: 211 | dist = (a * x + b * y + c) / a_b 212 | else: 213 | # Define a cubic polynomial equation for the y-coordinate 214 | # y0 at the point (x0, y0) on the curved edge that is closest to (x, y) 215 | d = -y + p[1] * p[2] - x * p[1] 216 | c = 1 + p[1] ** 2 + 2 * p[2] * p[0] - 2 * x * p[0] 217 | b = 3 * p[1] * p[0] 218 | a = 2 * p[0] ** 2 219 | 220 | if p[0] == 0.0: 221 | y0 = -d / c # solution if edge is straight (quadratic term is zero) 222 | else: 223 | y0 = self.cubic_solver(a, b, c, d) # edge is curved 224 | 225 | x0 = p[0] * y0 ** 2 + p[1] * y0 + p[2] 226 | dxx_dyy = np.array(2 * p[0] * y0 + p[1]) # slope at (x0, y0) 227 | r2 = self.dot([1, -dxx_dyy], [1, -dxx_dyy]) 228 | # distance between (x, y) and (x0, y0) along normal to curve at (x0, y0) 229 | dist = self.dot([x - x0, y - y0], [1, -dxx_dyy]) / np.sqrt(r2) 230 | return dist 231 | 232 | # @execution_timer 233 | def project_and_bin(self, data, dist): 234 | # p contains quadratic polynomial coefficients for the x-coordinate 235 | # of the curved edge as a function of the y-coordinate: 236 | # x = p[0]*y**2 + p[1]*y + p[2] 237 | 238 | # Create a matrix "bins" where each element represents the bin index of the 239 | # corresponding image pixel in "data": 240 | bins = np.round(dist * self.oversampling).astype(int) 241 | bins = bins.flatten() 242 | bins -= np.min(bins) # add an offset so that bins start at 0 243 | 244 | esf = np.zeros(np.max(bins) + 1) # Edge spread function 245 | cnts = np.zeros(np.max(bins) + 1).astype(int) 246 | data_flat = data.flatten() 247 | for b_indx, b_sorted in zip(np.argsort(bins), np.sort(bins)): 248 | esf[b_sorted] += data_flat[b_indx] # Collect pixel contributions in this bin 249 | cnts[b_sorted] += 1 # Keep a tab of how many contributions were made to this bin 250 | 251 | # Calculate mean by dividing by the number of contributing pixels. Avoid 252 | # division by zero, in case there are bins with no content. 253 | esf[cnts > 0] /= cnts[cnts > 0] 254 | if np.any(cnts == 0): 255 | if self.verbose: 256 | print("Warning: esf bins with zero pixel contributions were found. Results may be inaccurate.") 257 | print(f"Try reducing the oversampling factor, which currently is {self.oversampling:d}.") 258 | # Try to save the situation by patching in values in the empty bins if possible 259 | patch_cntr = 0 260 | for i in np.where(cnts == 0)[0]: # loop through all empty bin locations 261 | j = [i - 1, i + 1] # indices of nearest neighbors 262 | if j[0] < 0: # Is left neighbor index outside esf array? 263 | j = j[1] 264 | elif j[1] == len(cnts): # Is right neighbor index outside esf array? 265 | j = j[0] 266 | if np.all(cnts[j] > 0): # Now, if neighbor bins are non-empty 267 | esf[i] = np.mean(esf[j]) # use the interpolated value 268 | patch_cntr += 1 269 | if patch_cntr > 0 and self.verbose: 270 | print(f"Values in {patch_cntr:d} empty ESF bins were patched by " 271 | f"interpolation between their respective nearest neighbors.") 272 | return esf 273 | 274 | @staticmethod 275 | def peak_width(y, rel_threshold): 276 | # Find width of peak in y that is above a certain fraction of the maximum value 277 | val = np.abs(y) 278 | val_threshold = rel_threshold * np.max(val) 279 | indices = np.where(val - val_threshold > 0.0)[0] 280 | return indices[-1] - indices[0] 281 | 282 | def filter_window(self, lsf): 283 | # The window ('hann_win') returned by this function will be used as a filter 284 | # on the LSF signal during the MTF calculation to reduce noise 285 | 286 | nn0 = 20 * self.oversampling # sample range to be used for the FFT, intial guess 287 | mid = len(lsf) // 2 288 | i1 = max(0, mid - nn0) 289 | i2 = min(2 * mid, mid + nn0) 290 | nn = (i2 - i1) // 2 # sample range to be used, final 291 | 292 | # Filter LSF curve with a uniform kernel to better find center and 293 | # determine an appropriate Hann window width for noise reduction 294 | lsf_conv = np.convolve(lsf[i1:i2], np.ones(self.lsf_centering_kernel_sz), 'same') 295 | 296 | # Base Hann window half width on the width of the filtered LSF curve 297 | hann_hw = max(np.round(self.win_width_factor * self.peak_width(lsf_conv, self.lsf_threshold)).astype(int), 298 | 5 * self.oversampling) 299 | 300 | bin_c = np.argmax(np.abs(lsf_conv)) # center bin, corresponding to LSF max 301 | 302 | # Construct Hann window centered over the LSF peak, crop if necessary to 303 | # the range [0, 2*nn] 304 | crop_l = max(hann_hw - bin_c, 0) 305 | crop_r = min(2 * nn - (hann_hw + bin_c), 0) 306 | hann_win = np.zeros(2 * nn) # default value outside Hann function 307 | hann_win[bin_c - hann_hw + crop_l:bin_c + hann_hw + crop_r] = \ 308 | np.hanning(2 * hann_hw)[crop_l:2 * hann_hw + crop_r] 309 | return hann_win, 2 * hann_hw, [i1, i2] 310 | 311 | def calc_mtf(self, lsf, hann_win, idx): 312 | # Calculate MTF using the LSF as input and use the supplied window function 313 | # as filter to remove high frequency noise originating in regions far from 314 | # the edge 315 | 316 | i1, i2 = idx 317 | mtf = np.abs(np.fft.fft(lsf[i1:i2] * hann_win)) 318 | nn = (i2 - i1) // 2 319 | mtf = mtf[:nn] 320 | mtf /= mtf[0] # normalize to zero spatial frequency 321 | f = np.arange(0, self.oversampling / 2, self.oversampling / nn / 2) # spatial frequencies (cy/px) 322 | # Compensate for finite impulse response of the numerical differentiation 323 | # step used to derive the LSF from the ESF 324 | # NB: This compensation function is incorrect in both ISO 12233:2014 325 | # and ISO 12233:2017, Annex D 326 | mtf *= (1 / np.sinc(4 * f / (self.diff_ft * self.oversampling))).clip(0.0, 10.0) 327 | return np.column_stack((f, mtf)) 328 | 329 | # @execution_timer # call to timing module, comment out if not used 330 | def calc_sfr(self, image): 331 | """" 332 | Calculate spectral response function (SFR), this is the main function 333 | 334 | input: image patch with slanted edge to be analyzed (2-d Numpy array of float) 335 | output: MTF organized as a 2-d array where first column is spatial frequency, and second column 336 | contains MTF values 337 | output: status dict with fitted edge angle (c.w. from vertical), offset, image rotation etc. 338 | 339 | NB: SFR calculations will fail for edge angles of 0°, 45°, and 90° (an inherent limitation of the method) 340 | """ 341 | # TODO: apply Hann (or Hamming) window before calculating centroids, or 342 | # TODO: do a second pass after find_edge with windowing, centroids, and find_edge 343 | 344 | # Calculate centroids for the edge transition of each row 345 | sample_diff = self.differentiate(image) 346 | centr = self.centroid(sample_diff) + self.diff_offset 347 | 348 | # Calculate centroids also for the 90° right rotated image 349 | image_rot90 = image.T[:, ::-1] # rotate by transposing and mirroring 350 | sample_diff = self.differentiate(image_rot90) 351 | centr_rot = self.centroid(sample_diff) + self.diff_offset 352 | 353 | # Use rotated image if it results in fewer rows without edge transitions 354 | if np.sum(np.isnan(centr_rot)) < np.sum(np.isnan(centr)): 355 | self.verbose and print("Rotating image by 90°") 356 | image, centr = image_rot90, centr_rot 357 | rotated = True 358 | else: 359 | rotated = False 360 | 361 | # Finds polynomials that describes the slanted edge by least squares 362 | # regression to the centroids: 363 | # - pcoefs are the 2nd order fit coefficients 364 | # - [slope, offset] are the first order (linear) fit coefficients for the same edge 365 | pcoefs, slope, offset = self.find_edge(centr, image.shape, rotated) 366 | 367 | pcoefs = [0.0, slope, offset] if not self.quadratic_fit else pcoefs 368 | 369 | # Calculate distance (with sign) from each point (x, y) in the 370 | # image patch "data" to the slanted edge 371 | dist = self.calc_distance(image.shape, pcoefs) 372 | 373 | esf = self.project_and_bin(image, dist) # edge spread function 374 | 375 | lsf = self.differentiate(esf) # line spread function 376 | 377 | hann_win, hann_width, idx = self.filter_window(lsf) # define window to be applied on LSF 378 | 379 | mtf = self.calc_mtf(lsf, hann_win, idx) 380 | 381 | if self.show_plots >= 4 or self.return_fig: 382 | i1, i2 = idx 383 | nn = (i2 - i1) // 2 384 | lsf_sign = np.sign(np.mean(lsf[i1:i2] * hann_win)) 385 | 386 | fig, ax = plt.subplots() 387 | ax.plot(esf[i1:i2], 'b.-', label=f"ESF, oversampling: {self.oversampling:2d}") 388 | ax.plot(lsf_sign * lsf[i1:i2], 'r.-', label=f"{'-' if lsf_sign < 0 else ''}LSF") 389 | ax.plot(hann_win * ax.axes.get_ylim()[1] * 1.1, 'g.-', label=f"Hann window, width: {hann_width:d}") 390 | ax.set_xlim(0, 2 * nn) 391 | ax2 = ax.twinx() 392 | ax2.get_yaxis().set_visible(False) 393 | ax.grid() 394 | ax.legend(loc='upper left') 395 | ax.set_xlabel('Bin no.') 396 | if self.verbose: 397 | textstr = '\n'.join([f"Curved edge fit: {self.quadratic_fit}", 398 | f"Difference scheme: {self.difference_scheme}"]) 399 | props = dict(facecolor='wheat', alpha=0.5) 400 | ax.text(0.05, 0.50, textstr, transform=ax.transAxes, 401 | verticalalignment='top', bbox=props) 402 | 403 | angle = angle_from_slope(slope) 404 | angle_cw = rotated * 90.0 - angle # angle clockwise (c.w.) from vertical axis 405 | if angle_cw > 90.0: 406 | angle_cw -= 180.0 407 | status = {'rotated': rotated, 408 | 'angle': angle_cw, 409 | 'offset': offset} 410 | if self.return_fig: 411 | status.update({'fig': fig, 'ax': ax}) 412 | 413 | return mtf, status 414 | -------------------------------------------------------------------------------- /utils.py: -------------------------------------------------------------------------------- 1 | import matplotlib.pyplot as plt 2 | import numpy as np 3 | import scipy 4 | import copy 5 | import os 6 | 7 | 8 | class Raw: 9 | def __init__(self, height=2200, width=3200, shift=4, fmt='> self.shift 17 | return np.reshape(image, (self.height, self.width)).astype(float) 18 | 19 | def write(self, im_data, path): 20 | output = (im_data.flatten().astype(self.fmt) << self.shift).tobytes() 21 | with open(path, 'wb') as f: 22 | f.write(output) 23 | 24 | 25 | def airy_disk(lam_diffr, f_number, grid_spacing, psf_shape): 26 | y_max, x_max = psf_shape 27 | xx, yy = np.meshgrid(np.arange(y_max), np.arange(x_max)) 28 | r = np.sqrt((xx - 0.5 * x_max) ** 2 + (yy - 0.5 * y_max) ** 2) * grid_spacing 29 | r_prime = np.pi * r / (lam_diffr * f_number) 30 | r_prime = np.where(r_prime == 0, 1e-16, r_prime) 31 | psf_airy_disk = (2 * scipy.special.j1(r_prime) / r_prime) ** 2 32 | psf_airy_disk /= np.sum(psf_airy_disk) 33 | return psf_airy_disk 34 | 35 | 36 | def abs_and_remove_padding(c, pad_width): 37 | return np.abs(c[pad_width:-pad_width, pad_width:-pad_width]) if pad_width > 0 else np.abs(c) 38 | 39 | 40 | def rebin(arr, new_shape): 41 | shape = (new_shape[0], arr.shape[0] // new_shape[0], new_shape[1], arr.shape[1] // new_shape[1]) 42 | return arr.reshape(shape).mean(-1).mean(1) 43 | 44 | 45 | def slanted_edge_blurred_with_diffraction_only(shape, pixel_pitch, f_number, wavelength, 46 | pad_width=10, oversampling=10): 47 | import slanted_edge_target 48 | 49 | roi_height, roi_width = (np.array(shape) + 2 * pad_width) * oversampling 50 | im_roi = slanted_edge_target.make_ideal_slanted_edge((roi_height, roi_width)) 51 | 52 | grid_spacing = pixel_pitch / oversampling # pixel_pitch is given in m 53 | 54 | def calc_psf_shape(lam_diffr, f_number, grid_spacing, n=3): 55 | # Calculate a psf_kernel size suitable for the psf blur size and pixel pitch 56 | sz = int(n * lam_diffr * f_number * 2.44 / grid_spacing) # set as n x first minimum dia. 57 | return sz, sz 58 | 59 | psf_shape = calc_psf_shape(wavelength, f_number, grid_spacing) 60 | psf_diff = airy_disk(wavelength, f_number, grid_spacing, psf_shape) 61 | 62 | blurred_im_roi = scipy.signal.convolve2d(im_roi, psf_diff, mode='same') 63 | 64 | blurred_im_roi = rebin(blurred_im_roi, (np.array(blurred_im_roi.shape) / oversampling).astype(int)) 65 | blurred_im_roi = abs_and_remove_padding(blurred_im_roi, pad_width) 66 | 67 | # try with Fourier transforms to speed up things 68 | ft_psf = np.fft.fftshift(np.fft.fft2(airy_disk(wavelength, f_number, grid_spacing, im_roi.shape))) 69 | ft_im_roi = np.fft.fftshift(np.fft.fft2(im_roi)) 70 | blurred_im_roi_from_ft = np.abs(np.fft.fftshift(np.fft.ifft2(ft_psf * ft_im_roi))) 71 | blurred_im_roi_from_ft = rebin(blurred_im_roi_from_ft, 72 | (np.array(blurred_im_roi_from_ft.shape) / oversampling).astype(int)) 73 | blurred_im_roi_from_ft = abs_and_remove_padding(blurred_im_roi_from_ft, pad_width) 74 | 75 | return blurred_im_roi, blurred_im_roi_from_ft 76 | 77 | 78 | def extrap_mtf(input_lens_mtf, pixel_pitch, fit_begin=[0.54, 0.63], fit_end=[0.90, 0.81], mtf_fit_limit=[0.40], 79 | mtf_tail_lvl=0.05, extend_to_fit=True): 80 | """ 81 | The system / sensor MTF curve is unreliable at high spatial frequency, where the sensor MTF is small. 82 | Therefore, we fit the lens MTF curve in a frequency interval and extrapolate down to zero MTF beyond that 83 | interval with a soft tail. If needed, more points added to fit the whole MTF curve down to ~zero MTF. 84 | 85 | :param input_lens_mtf: numpy array with spatial frequencies in cy/mm in the first row, 86 | and MTF (0.0-1.0) values in the second row, which are obtained by dividing the system MTF by 87 | the image sensor MTF (e.g. a sinc function) 88 | :param pixel_pitch: pixel pitch of the image sensor (µm) 89 | :param fit_begin: list of two alternative start points for the fit interval (in Nyquist frequency units) 90 | :param fit_end: list of two alternative end points for the fit interval (in Nyquist frequency units) 91 | :param mtf_fit_limit: list of one MTF level at which we switch from trying to use the first fit interval to the second 92 | :param mtf_tail_lvl: MTF beneath which the soft tail starts 93 | :param extend_to_fit: Add more points to fit the whole MTF curve (if necessary) 94 | :return: 2-d numpy array with spatial frequencies and lens MTF, 95 | [fit range start used, fit range end used] in units of cy/mm 96 | """ 97 | f_nyquist = 1000 / pixel_pitch / 2 98 | f, mtf = input_lens_mtf[:, 0], input_lens_mtf[:, 1] 99 | mtf_ = scipy.interpolate.interp1d(f, mtf) 100 | 101 | k = 1 if mtf_(fit_end[0] * f_nyquist) < mtf_fit_limit[0] else 0 102 | i = np.argwhere((fit_begin[k] * f_nyquist <= f) & (f <= fit_end[k] * f_nyquist)).squeeze() 103 | slope, offset = np.polyfit(f[i].squeeze(), mtf[i].squeeze(), 1) 104 | 105 | x = copy.copy(f) 106 | run_look = True 107 | while run_look: 108 | y = offset + slope * x 109 | i0 = i[0] 110 | y[0:i0] = mtf[0:i0].squeeze() 111 | y[i0] = np.mean([mtf[i0], y[i0]]) 112 | 113 | if any(y < mtf_tail_lvl): 114 | j0 = np.argwhere(y < mtf_tail_lvl)[0] 115 | else: 116 | j0 = len(y) - 1 117 | 118 | if extend_to_fit: 119 | if j0 < (len(y) - 10): 120 | run_look = False 121 | else: 122 | x_ext = x[1:11] - x[0] + x[-1] 123 | x = np.append(x, x_ext) 124 | else: 125 | run_look = False 126 | 127 | j = np.arange(j0, len(y)) 128 | y[j] = y[j0] * (y[j0] / y[j0 - 1]) ** (j - j0) 129 | return np.column_stack([x, y]), [fit_begin[k], fit_end[k]] 130 | 131 | 132 | def plot_image_and_crop_roi(fig, ax1, im, xc, yc, roi_height=80, roi_width=80): 133 | import selection_tools 134 | ax1.imshow(im, cmap='gray') 135 | r = selection_tools.RectXY(fig, ax1) 136 | fig.canvas.mpl_connect('motion_notify_event', r.move) 137 | fig.canvas.mpl_connect('button_press_event', r.fix_or_release) 138 | fig.canvas.mpl_connect('key_release_event', r.keypress) 139 | roi_data = [{'x_center': xc, 'y_center': yc, 'height': roi_height, 'width': roi_width}] 140 | r.populate_roi_data(roi_data) 141 | # (selection_tools supports using multiple ROIs on the same image, but here we only use one ROI per image) 142 | im_roi = selection_tools.crop(im, roi_data)[0] 143 | return im_roi 144 | 145 | 146 | class MTFplotter: 147 | def __init__(self, pixel_pitch, f_number=2.8, lam_diffr=550e-9, x_lims=[0, 120], 148 | fit_begin=[0.54, 0.63], fit_end=[0.90, 0.81], mtf_fit_limit=[0.40], mtf_tail_lvl=0.05): 149 | self.pixel_pitch = pixel_pitch # pixel pitch (µm) 150 | self.f_nyquist = 1000 / pixel_pitch / 2 # Nyquist frequency of the camera system (cy/mm) 151 | self.f_number = f_number # f-number for which we calculate diffraction 152 | self.lam_diffr = lam_diffr # wavelength for which we calculate diffraction 153 | self.x_lims = x_lims # plot limits for spatial frequency (cy/mm) 154 | self.fit_begin = fit_begin # Two alternative start points for the fit interval, in Nyquist frequency units 155 | self.fit_end = fit_end # Two alternative end points for the fit interval 156 | self.mtf_fit_limit = mtf_fit_limit # Where we switch from trying to use the first fit interval to the second 157 | self.mtf_tail_lvl = mtf_tail_lvl 158 | 159 | def calc_and_plot_mtf(self, ax, im_roi, x_lims=None, plot=True): 160 | import utils 161 | import SFR 162 | # Calculate MTF from the ROI with the slanted edge 163 | sfr_lin = SFR.SFR(quadratic_fit=False) # force fitting to a straight edge, as in the ISO 12233 standard 164 | mtf_lin, status = sfr_lin.calc_sfr(im_roi) 165 | sfr = SFR.SFR() # allow fitting to a 2nd order polynomial edge shape 166 | mtf, status = sfr.calc_sfr(im_roi) 167 | 168 | f0 = 2 * self.f_nyquist # limit frequency (cy/mm) where sensor MTF == 0 169 | mtf_lin[:, 0] *= f0 170 | mtf[:, 0] *= f0 171 | 172 | mtf_system = mtf 173 | # ideal sensor MTF: 100% fill factor, no crosstalk 174 | mtf_sinc = np.column_stack( 175 | [mtf_system[:, 0], np.abs(np.sinc(mtf_system[:, 0] / f0))]) # TODO: add support for fill factor 176 | mtf_lens_raw = np.column_stack([mtf_system[:, 0], mtf_system[:, 1] / (mtf_sinc[:, 1] + 1e-8)]) 177 | 178 | # The system / sensor MTF curve is unreliable at high spatial frequency, where the sensor MTF is small. 179 | # Therefore, we fit the lens MTF curve in a frequency interval and extrapolate down to zero MTF beyond that 180 | # interval with a soft tail. If needed, more points added to fit the whole MTF curve down to ~zero MTF. 181 | mtf_lens, fit_range_used = \ 182 | utils.extrap_mtf(mtf_lens_raw, self.pixel_pitch, fit_begin=self.fit_begin, fit_end=self.fit_end, 183 | mtf_fit_limit=self.mtf_fit_limit, mtf_tail_lvl=self.mtf_tail_lvl, extend_to_fit=True) 184 | 185 | # Diffraction limit MTF for reference (valid at wavelength == lam_diffr) 186 | mtf_diff = np.column_stack([mtf_lens[:, 0], 187 | utils.mtf_diffraction_limit(self.f_number, self.lam_diffr, mtf_lens[:, 0])]) 188 | if plot: 189 | ax.plot(mtf_lin[:, 0], mtf_lin[:, 1], '.:', color='C0', label="system MTF (lin. edge fit)") 190 | ax.plot(mtf_system[:, 0], mtf_system[:, 1], '.-', color='C1', label="system MTF") 191 | ax.plot(mtf_sinc[:, 0], mtf_sinc[:, 1], 'k-', label="ideal sensor MTF") 192 | ax.plot(mtf_lens_raw[:, 0], mtf_lens_raw[:, 1], '--', color='C2', label="system MTF / sensor MTF") 193 | ax.plot(mtf_lens[:, 0], mtf_lens[:, 1], '-.', color='C3', 194 | label=f"lens MTF, fitted btw {fit_range_used[0] * self.f_nyquist:.0f}" 195 | f" and {fit_range_used[1] * self.f_nyquist:.0f} cy/mm") 196 | ax.plot(mtf_diff[:, 0], mtf_diff[:, 1], ':k', 197 | label=f'diffraction limit for {self.lam_diffr / 1e-9:.0f} nm, f/{self.f_number:.1f}') 198 | 199 | ax.set_xlim(*(x_lims if x_lims else self.x_lims)) 200 | ax.set_ylim(0, 1.2) 201 | ax.grid() 202 | ax.set_ylabel('MTF') 203 | ax.set_xlabel('Spatial frequency (cy/mm)') 204 | ax.legend(loc='best') 205 | return mtf_system, mtf_lens, status 206 | 207 | 208 | def solve_for_r(r0, alpha_vec): 209 | def func(r, _r0, ca, sa): 210 | lhs = np.tan(_r0) ** 2 211 | rhs = np.tan(r * ca) ** 2 + np.tan(r * sa) ** 2 212 | return lhs - rhs 213 | 214 | _r0 = np.deg2rad(r0) 215 | rx, ry = [], [] 216 | for alpha in alpha_vec: 217 | ca, sa = np.cos(alpha), np.sin(alpha) 218 | r = np.rad2deg( 219 | scipy.optimize.fsolve(func, _r0, args=(_r0, ca, sa)))[0] 220 | rx.append(r * ca) 221 | ry.append(r * sa) 222 | return rx, ry 223 | 224 | 225 | def plot_contour(data_h, data_v, range_lo, range_hi, lim_min, lim_av, text, 226 | save_folder, radius0=38.0, radius1=55.0, save_to_file=True): 227 | """ 228 | Plot the MTF values at a specific spatial frequency vs. horizontal and vertical field angles as a colored 229 | contour plot. 230 | :param data_h: np.array of three columns, 1st hor. field angle, 2nd vert. field angle, 3rd MTF value in hor. dir. 231 | :param data_v: as data_h, but with 3rd column containing MTF value measured in vert. direction 232 | :param range_lo: lowest MTF value to be plotted as a contour 233 | :param range_hi: highest MTF value to be plotted as a contour 234 | :param lim_min: specification limit for the min(horizontal MTF, vertical MTF) plot 235 | :param lim_av: specification limit for the (horizontal MTF + vertical MTF) / 2 plot 236 | :param text: text to be displayed in the plot title as basis for the figure save filename 237 | :param save_folder: where to save the figure 238 | :param radius0: radius of inner field angle circle (in degrees) 239 | :param radius1: radius of outer field angle circle (in degrees) 240 | :param save_to_file: save plots to file if True 241 | :return: 242 | """ 243 | data_av = np.column_stack([data_h[:, :2], 0.5 * (data_h[:, 2] + data_v[:, 2])]) 244 | data_min = np.column_stack([data_h[:, :2], np.minimum(data_h[:, 2], data_v[:, 2])]) 245 | for g, direction, contrast_limit in zip([data_h, data_v, data_av, data_min], ['hor.', 'vert.', 'av.', 'min.'], 246 | [lim_min, lim_min, lim_av, lim_min]): 247 | if direction in ['hor.', 'vert.']: 248 | continue 249 | points = g[:, :2] 250 | values = g[:, 2] 251 | xx, yy = np.meshgrid(np.arange(-radius1, radius1 + 1), np.arange(-radius0, radius0 + 1)) 252 | grid_points = np.column_stack([xx.flatten(), yy.flatten()]) 253 | zz = scipy.interpolate.griddata(points, values, grid_points, method='linear') 254 | zz = zz.reshape(xx.shape) 255 | plt.figure() 256 | cs = plt.contourf(xx, yy, zz, levels=np.arange(range_lo, range_hi + 0.01, 0.05)) 257 | colors = ['r' if z <= contrast_limit else 'k' for z in cs.levels] 258 | 259 | linestyles = 'solid' 260 | linewidths = [1.5 if (z / 0.05).astype(int) % 2 else 1.0 for z in cs.levels] 261 | cs2 = plt.contour(cs, levels=cs.levels, linestyles=linestyles, linewidths=linewidths, colors=colors) 262 | cbar = plt.colorbar(cs) 263 | cbar.add_lines(cs2) 264 | a = np.linspace(0, 2 * np.pi, 101) 265 | for r0 in [radius0, radius1]: 266 | 267 | # Find horizontal and vertical field angle components corresponding to the 268 | # azimuth angle 'a' and the total field angle 'r0', 269 | # i.e., find tan(r0)**2 = tan(rx)**2 + tan(ry)**2, where rx = r * cos(a), ry = r * sin(a) 270 | rx, ry = solve_for_r(r0, a) 271 | plt.plot(rx, ry, 'k--') 272 | 273 | plt.plot(points[:, 0], points[:, 1], '.k') 274 | plt.gca().axis('equal') 275 | plt.ylim([-radius0, radius0]) 276 | plt.xlim([-radius1, radius1]) 277 | plt.xlabel('Horizontal field angle (°)') 278 | plt.ylabel('Vertical field angle (°)') 279 | txt = f'{text}, {direction} MTF' 280 | plt.title(txt) 281 | if save_to_file: 282 | # remove/replace unsuitable characters from the title text for use as a filename 283 | filename = txt.replace('/', '_').replace(' ', '_').replace(',', '').replace('.', '') 284 | fpath = os.path.join(save_folder, filename + '.png') 285 | dpi = 200 286 | plt.savefig(fpath, dpi=dpi) 287 | plt.close() 288 | if not save_to_file: 289 | plt.show() 290 | 291 | 292 | def read_8bit(path): 293 | """ 294 | Reads .bmp, .png, .jpg, etc. files 295 | Uses plt.imread(), which in turn calls PIL.Image.open() 296 | Note that the .pgm implementation of PIL is badly broken (more specifically, PGM P2 and P5 16-bit is broken, 297 | however, PGM P5 8-bit works), so we have dedicated functions for loading and saving images in these formats. 298 | 299 | From the Matplotlib documentation: 300 | 301 | The returned array has shape 302 | (M, N) for grayscale images. 303 | (M, N, 3) for RGB images. 304 | (M, N, 4) for RGBA images. 305 | 306 | PNG images are returned as float arrays (0-1). All other formats are returned as int arrays, 307 | with a bit depth determined by the file's contents. 308 | """ 309 | return plt.imread(path).astype(float) 310 | 311 | 312 | def read_pgm(file_path): 313 | """ 314 | Read .pgm image file in either 315 | P2 (ASCII text) format, or 316 | P5 (either 8-bit unsigned, or big endian 16-bit unsigned binary) format 317 | input: file path 318 | output: 2-d numpy array of float 319 | """ 320 | 321 | # Read header as binary file in order to avoid "'charmap' codec can't decode byte" errors 322 | with open(file_path, 'rb') as f: 323 | lines = [] 324 | while len(lines) < 3: 325 | new_line = f.readline().strip().decode("ascii") 326 | if new_line[0] != '#': 327 | lines.append(new_line) 328 | image_data_start = f.tell() 329 | 330 | magic_number = lines[0] 331 | cols, rows = [int(v) for v in lines[1].split()] 332 | max_val = int(lines[2]) 333 | 334 | if magic_number in 'P2': # convert ASCII format (P2) data into a list of integers 335 | with open(file_path, 'r') as f: 336 | f.seek(image_data_start) # read file again, but this time as a text file; skip the metadata lines 337 | lines = f.readlines() 338 | image_data = [] 339 | for line in lines: # skip the metadata lines 340 | image_data.extend([int(c) for c in line.split()]) 341 | 342 | elif magic_number in 'P5': 343 | # Read and convert the binary format (P5) data into an array of integers 344 | fmt = 'u1' if max_val < 256 else '>u2' # either 8-bit unsigned, or big endian 16-bit unsigned 345 | image_data = np.fromfile(file_path, offset=image_data_start, dtype=np.dtype(fmt)) 346 | 347 | return np.reshape(np.array(image_data), (rows, cols)).astype(float) 348 | 349 | 350 | def write_pgm(im_data, file_path, magic_number='P5', comment=''): 351 | """ 352 | Write .pgm image file in either 353 | P2 (ASCII text) format, or 354 | P5 (either 8-bit unsigned, or big endian 16-bit unsigned binary) format, depending on max value in im_data 355 | im_data: image data as 2-d numpy array of float or int 356 | input: file_path 357 | magic_number: either 'P5' (binary data) or 'P2' (ASCII data), default is 'P5' 358 | comment: comment line to be added in the metadata section, default is None 359 | """ 360 | 361 | im_data = im_data.astype(int) 362 | 363 | comment_line = '# ' + comment 364 | rows, cols = im_data.shape 365 | size = str(cols) + ' ' + str(rows) 366 | max_val = str(255) if np.max(im_data) < 256 else str(65535) 367 | 368 | meta_data_lines = [magic_number, comment_line, size, max_val] 369 | 370 | with open(file_path, 'w', newline='\n') as f: 371 | for line in meta_data_lines: 372 | f.write(line + '\n') 373 | 374 | def limit_line_length(im_data_string, char_limit=70): 375 | # Divide the string of image values (im_data_string) into a list of lines (output_lines). In order to comply 376 | # with the .pgm P2 format, the lines must not exceed 70 characters (char_limit) in length. 377 | i, j, output_lines = 0, char_limit, [] 378 | len_im_data = len(im_data_string) 379 | while i < len_im_data: 380 | while (i + j + 1) > len_im_data or im_data_string[i + j] != ' ': 381 | j -= 1 382 | if (i + j) == len_im_data: 383 | break 384 | output_lines.append(im_data_string[i:i + j] + '\n') 385 | i += j 386 | j = char_limit 387 | return output_lines 388 | 389 | if magic_number in 'P2': 390 | # append data in ASCII format 391 | image_data_string = ' '.join([str(d) for d in im_data.flatten()]) # string with values separated by blanks 392 | lines = limit_line_length(image_data_string) # divide string into lines with a max length of 70 characters 393 | with open(file_path, 'a', newline='\n') as f: 394 | f.writelines(lines) 395 | 396 | elif magic_number in 'P5': 397 | # append either 8-bit unsigned, or big endian 16-bit unsigned image data 398 | fmt = 'u1' if int(max_val) < 256 else '>u2' 399 | output = im_data.astype(fmt).tobytes() 400 | with open(file_path, 'ab') as f: 401 | f.write(output) 402 | 403 | 404 | def relative_luminance(rgb_image, rgb_w=(0.2126, 0.7152, 0.0722)): 405 | # Return relative luminance of image, based on sRGB MxNx3 (or MxNx4) input 406 | # Default weights rgb_w are the ones for the sRGB colorspace 407 | if rgb_image.ndim == 2: 408 | return rgb_image # do nothing, this is an MxN image without color data 409 | else: 410 | return rgb_w[0] * rgb_image[:, :, 0] + rgb_w[1] * rgb_image[:, :, 1] + rgb_w[2] * rgb_image[:, :, 2] 411 | 412 | 413 | def rgb2gray(im_rgb, im_0, im_1): 414 | """Flatten a Bayer pattern image by using the mean color channel signals in two flat luminance regions (one darker, 415 | one lighter). In addition, return also the estimated pedestal and estimated relative color gains. 416 | The underlying assumption is that the signal can be described as pedestal + color_gain * luminance (+ noise) 417 | 418 | Input: 419 | im_rgb: 2-d numpy array of float 420 | color image with a 2x2 color filter array (CFA) pattern, such as RGGB, RCCG, RYYB, RGBC, etc. 421 | im_0: 2-d numpy array of float 422 | a part of im_rgb with constant luminance 423 | im_1: 2-d numpy array of float 424 | another part im_rgb with a constant luminance which is different from that in im_0 425 | Output: 426 | a 2-d numpy array of float 427 | this is the flattened image, normalized as if all pixels had the same color, more specifically the color 428 | with the strongest color_gain of the four colors in the CFA 429 | pedestal: float 430 | the estimated pedestal from the solution to the overdetermined equation system 431 | rev_gain: 2x2 numpy array of float 432 | the estimated reverse gains of the four color filters in the CFA, normalized to the strongest of the 433 | color gains 434 | """ 435 | 436 | c_0 = [np.mean(im_0[i::2, j::2]) for i, j in ((0, 0), (0, 1), (1, 0), (1, 1))] 437 | c_1 = [np.mean(im_1[i::2, j::2]) for i, j in ((0, 0), (0, 1), (1, 0), (1, 1))] 438 | 439 | # Define and solve the following overdetermined equation system: 440 | # c_1 - pedestal = lum_ratio * (c_0 - pedestal) 441 | # Solve Ax = b for x, where x = [pedestal * (1 - lum_ratio), lum_ratio] 442 | A = np.array([[1, c_0[i]] for i in range(4)]) 443 | b = np.array([c_1[i] for i in range(4)]) 444 | x, _, _, _ = np.linalg.lstsq(A, b, rcond=None) 445 | lum_ratio = x[1] # luminance ratio between light and dark sides of the edge 446 | pedestal = x[0] / (1 - lum_ratio) # estimate the pedestal that is added to the RGB image (not affected by RGB gain) 447 | 448 | # Estimate dark side RGB signal without the pedestal 449 | c_0_p = [np.mean([c_0[i] - pedestal, (c_1[i] - pedestal) / lum_ratio]) for i in range(4)] 450 | 451 | # Define a reverse gain image that will flatten the RGB image, and normalize it to the 452 | # green channel (i.e. as if all pixels were green in an RGB image) 453 | c_p_max = np.max(c_0_p) 454 | rev_gain = np.array([c_p_max / c_0_p[k] for k in range(4)]) 455 | rev_gain_image = np.zeros_like(im_rgb) 456 | for k, (i, j) in enumerate(((0, 0), (0, 1), (1, 0), (1, 1))): 457 | rev_gain_image[i::2, j::2] = rev_gain[k] 458 | 459 | # return flattened image 460 | return (im_rgb - pedestal) * rev_gain_image + pedestal, pedestal, rev_gain 461 | 462 | 463 | def mtf_diffraction_limit(f_num, lam, f): 464 | """ Optical transfer function (OTF) for a diffraction limited lens. The OTF is calculated as the autocorrelation 465 | of a circular aperture. 466 | Paramters: 467 | f_num: float 468 | f-number of the lens aperture 469 | lam: float 470 | wavelength in m 471 | f: numpy array of float 472 | spatial frequencies in cy/mm 473 | """ 474 | v = lam / 1e-3 * f * f_num 475 | v = v.clip(0.0, 1.0) if isinstance(v, np.ndarray) else np.min((v, 1.0)) 476 | return 2 / np.pi * (np.arccos(v) - v * np.sqrt(1 - v ** 2)) 477 | 478 | 479 | def test(): 480 | import utils 481 | 482 | # Test calculation of diffraction limited MTF 483 | f_max = 600 484 | f = np.linspace(0, f_max, f_max + 1) # spatial frequency in cy/mm 485 | f_num = 4.0 # f-number 486 | lam = 500e-9 # wavelength in m 487 | mtf = utils.mtf_diffraction_limit(f_num, lam, f) 488 | plt.figure() 489 | plt.plot(f, mtf, '.-') 490 | plt.grid('both', 'both') 491 | plt.title(f'Diffraction limited MTF for {lam / 1e-9:.0f} nm wavelength and f/{f_num}') 492 | plt.show() 493 | 494 | # Test reading image file in .pgm P2 format (ASCII string) 495 | plt.figure() 496 | file_path = "test_pgm_P2.pgm" 497 | im = utils.read_pgm(file_path) 498 | plt.imshow(im, cmap='gray') 499 | plt.title(f'PGM P2 (ASCII) file: {file_path}') 500 | 501 | # Test writing / reading raw binary file 502 | utils.Raw().write(im, "test_raw.raw") 503 | im2 = utils.Raw(*im.shape).read("test_raw.raw") 504 | total_diff = np.sum(np.abs(im2 - im)) 505 | print(f'Total difference between original and written/read raw file: {total_diff}') 506 | 507 | # Test writing/reading different .pgm formats 508 | utils.write_pgm(im, "test_ascii.pgm", magic_number='P2', comment='P2 ASCII') 509 | utils.write_pgm(im, "test_binary_uint8.pgm", magic_number='P5', comment='P5 binary uint8') 510 | utils.write_pgm(im + 300, "test_binary_uint16.pgm", magic_number='P5', comment='P5 binary uint16') 511 | 512 | for file_path in ["test_ascii.pgm", "test_binary_uint8.pgm", "test_binary_uint16.pgm"]: 513 | plt.figure() 514 | im = utils.read_pgm(file_path) 515 | plt.title(f'PGM file: {file_path}') 516 | plt.imshow(im, cmap='gray') 517 | plt.show() 518 | 519 | 520 | if __name__ == '__main__': 521 | test() 522 | --------------------------------------------------------------------------------