├── 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 |
--------------------------------------------------------------------------------