Files
2025-11-10 11:11:52 +08:00

642 lines
21 KiB
Python

"""Modulation Transfer Function Module for slanted edge figures
The optical transfer function (OTF) of an optical system specifies
how different spatial frequencies are handled by the system.
It is used by optical engineers to describe how the optics project
light from the object or scene onto detector or simply the next item
in the optical transmission chain.
A variant, the modulation transfer function (MTF), neglects phase effects,
but is equivalent to the OTF in many situations.
look also: https://en.wikipedia.org/wiki/Optical_transfer_function
In normal operation MTF is fourier transform of Point Spread Function.
But for slanted edge targeted MTF calculations we use LSF instead of PSF.
This is because we assume LSF as cross-section of PSF along slant.
LSF can be optained using detivative of Edge Spread Function (ESF).
For line scan operations it is better to calculate along track and
across track MTF's.
Also for non symetrical pixel pitch/sizes it will also be convinient
to calculate horizontal and vertical slants.
Note that, the prefered slant tilt is between 2 degree and 10 degree.
Dependencies
Pillow numpy scipy matpilotlib opencv-python
"""
import matplotlib.pyplot as plt
import pylab as pylab
import numpy as np
import cv2 as cv2
import math as math
from PIL import Image, ImageOps
from scipy import interpolate
from scipy.fft import fft
from enum import Enum
from dataclasses import dataclass
from show import plot_results
from scipy.fft import fft, fftshift, fftfreq
@dataclass
class cSet:
"""
Base class to store index value pairs
...
Attributes
x : np.ndarray
numpy array to store indexes
y : np.ndarray
numpy array to store values
"""
x: np.ndarray
y: np.ndarray
@dataclass
class cESF:
"""
Base class to store Edge Spread Function (ESF) analysis output
...
Attributes
rawESF : cSet
ESF optained from image
interpESF : cSet
Interpolated ESF of rawESF
threshold :float
threshold used to get ESF
width : float
pixel transition width
angle : float
slant angle in degrees
edgePoly : np.ndarray
polynomial of edge slant
"""
rawESF: cSet
interpESF: cSet
threshold:float
width: float
angle: float
edgePoly: np.ndarray
@dataclass
class cMTF:
"""
Base class to Modulation Transfer Function (MTF) values
...
Attributes
x : np.ndarray
Frequency axis (normalized to Nyquist if pixel_size_mm is None,
otherwise spatial frequency in line pairs per millimeter)
y : np.ndarray
MTF values
mtfAtNyquist : float
MTF value at Nyquist Frequency (percentage)
width : float
Pixel transition
"""
x: np.ndarray
y: np.ndarray
mtfAtNyquist: float
width: float
class Verbosity(Enum):
"""
Output types of module methods
...
Enumerations
NONE : no output
BRIEF : brif text output
DETAIL : graphical output
"""
NONE = 0
BRIEF = 1
DETAIL = 2
class Helper:
@staticmethod
def LoadImage(filename):
"""
Load image from given path
...
Parameters
filename : str
a fully quialified file name of the image
...
Returns
PIL.Image
Grayscale image data
"""
img = Image.open(filename)
if img.mode in {'I;16','I;16L','I;16B','I;16N'}:
gsimg = img
else:
gsimg = img.convert('L')
return gsimg
@staticmethod
def LoadImageAsArray(filename):
"""
Load and convert image from given path to numpy array
...
Parameters
filename : str
a fully quialified file name of the image
...
Returns
np.darray
Grayscale image data as numpy array with values between 0.0 and 1.0
"""
img = Helper.LoadImage(filename)
if img.mode in {'I;16','I;16L','I;16B','I;16N'}:
arr = np.asarray(img, dtype=np.double)/65535
else:
arr = np.asarray(img, dtype=np.double)/255
return arr
@staticmethod
def ImageToArray(img):
"""
Convert PIL image to numpy array
...
Parameters
img : PIL.Image
Source image
...
Returns
np.darray
Grayscale image data as numpy array with values between 0.0 and 1.0
"""
if img.mode in {'I;16','I;16L','I;16B','I;16N'}:
arr = np.asarray(img, dtype=np.double)/65535
else:
arr = np.asarray(img, dtype=np.double)/255
return arr
@staticmethod
def ArrayToImage(imgArr):
"""
Convert numpy array to PIL image
...
Parameters
imgArr : np.darray
Image data as numpy array having values between 0.0 and 1.0
...
Returns
PIL.Image
Grayscale PIL image
"""
img = Image.fromarray(imgArr*255, mode='L')
return img
@staticmethod
def CorrectImageOrientation(imgArr):
"""
Rotate, transpose, flip image to get correct orientation for analysis
Module assumes a "dark side up" horizontal slanted image
...
Parameters
imgArr : np.darray
Image data as numpy array having values between 0.0 and 1.0
...
Returns
np.darray
Orientation corrected image array
"""
tl = np.average(imgArr[0:2,0:2])
tr = np.average(imgArr[0:2,-3:-1])
bl = np.average(imgArr[-3:-1,0:2])
br = np.average(imgArr[-3:-1,-3:-1])
edges = [tl, tr, bl, br]
edgeIndexes = np.argsort(edges)
if (edgeIndexes[0] + edgeIndexes[1]) == 1:
pass
elif (edgeIndexes[0] + edgeIndexes[1]) == 5:
imgArr = np.flip(imgArr, axis=0)
elif (edgeIndexes[0] + edgeIndexes[1]) == 2:
imgArr = np.transpose(imgArr)
elif (edgeIndexes[0] + edgeIndexes[1]) == 4:
imgArr = np.flip(np.transpose(imgArr), axis=0)
return imgArr
class MTF:
@staticmethod
def SafeCrop(values, distances, head, tail):
"""
Safely crop a index-value array from head to tail
Note that, method does not crop operation uses values of distance
not index itself
...
Parameters
value : np.darray
Value array
distances : np.darray
Index array
head : float
Desired crop start index value
tail : float
Desired crop end index value
...
Returns
cSet
crop result as cSet
"""
isIncrementing = True
if distances[0] > distances[-1]:
isIncrementing = False
distances = -distances
dummy = -tail
tail = -head
head = dummy
hindex = (np.where(distances < head)[0])
tindex = (np.where(distances > tail)[0])
if hindex.size < 2:
h = 0
else:
h = np.amax(hindex)
if tindex.size == 0:
t = distances.size
else:
t = np.amin(tindex)
if isIncrementing == False:
distances = -distances
return cSet(distances[h:t], values[h:t])
@staticmethod
def GetEdgeSpreadFunction(imgArr, edgePoly, verbose=Verbosity.NONE):
"""
Calculate Edge Spred Function (ESF)
ESF is simply distance map of every pixel to the given edge polynomial
https://en.wikipedia.org/wiki/Distance_from_a_point_to_a_line
...
Parameters
imgArr : np.darray
Image data as numpy array having values between 0.0 and 1.0
edgePoly : np.darray
Edge polynomial parameters
verbose : Verbosity
Output verbosity level
...
Returns
cSet
ESF as value index set
"""
Y = imgArr.shape[0]
X = imgArr.shape[1]
values = np.reshape(imgArr, X*Y)
distance = np.zeros((Y,X))
column = np.arange(0,X)+0.5
for y in range(Y):
distance[y,:] = (edgePoly[0]*column - (y+0.5) + edgePoly[1]) / np.sqrt(edgePoly[0]*edgePoly[0] + 1)
distances = np.reshape(distance, X*Y)
indexes = np.argsort(distances)
sign = 1
if np.average(values[indexes[:10]]) > np.average(values[indexes[-10:]]):
sign = -1
values = values[indexes]
distances = sign*distances[indexes]
if (distances[0] > distances[-1]):
distances = np.flip(distances)
values = np.flip(values)
if (verbose == Verbosity.BRIEF):
print("Raw ESF [done] (Distance from {0:2.2f} to {1:2.2f})".format(sign*distances[0], sign*distances[-1]))
elif (verbose == Verbosity.DETAIL):
x = [0, np.size(imgArr,1)-1]
y = np.polyval(edgePoly, x)
fig = pylab.gcf()
fig.canvas.manager.set_window_title('Raw ESF')
(ax1, ax2) = plt.subplots(2)
ax1.imshow(imgArr, cmap='gray', vmin=0.0, vmax=1.0)
ax1.plot(x, y, color='red')
ax2.plot(distances, values)
plt.show()
plt.show(block=False)
return cSet(distances, values)
@staticmethod
def GetEdgeSpreadFunctionCrop(imgArr, verbose=Verbosity.NONE):
"""
Calculate and crop Edge Spread Function (ESF).
Crop occures around center of transition
...
Parameters
imgArr : np.darray
Image data as numpy array having values between 0.0 and 1.0
verbose : Verbosity
Output verbosity level
...
Returns
cESF
Extended ESF information
"""
imgArr = Helper.CorrectImageOrientation(imgArr)
edgeImg = cv2.Canny(np.uint8(imgArr*255), 40, 90, L2gradient=True)
line = np.argwhere(edgeImg == 255)
edgePoly = np.polyfit(line[:,1],line[:,0],1)
angle = math.degrees(math.atan(-edgePoly[0]))
finalEdgePoly = edgePoly.copy()
if angle > 0:
imgArr = np.flip(imgArr, axis=1)
finalEdgePoly[1] = np.polyval(edgePoly,np.size(imgArr,1)-1)
finalEdgePoly[0] = -edgePoly[0]
esf = MTF.GetEdgeSpreadFunction(imgArr, finalEdgePoly, Verbosity.NONE)
esfValues = esf.y
esfDistances = esf.x
maximum = np.amax(esfValues)
minimum = np.amin(esfValues)
threshold = (maximum - minimum) * 0.1
head = np.amax(esfDistances[(np.where(esfValues < minimum + threshold))[0]])
tail = np.amin(esfDistances[(np.where(esfValues > maximum - threshold))[0]])
width = abs(head-tail)
esfRaw = MTF.SafeCrop(esfValues, esfDistances, head - 1.2*width, tail + 1.2*width)
qs = np.linspace(0,1,20)[1:-1]
knots = np.quantile(esfRaw.x, qs)
tck = interpolate.splrep(esfRaw.x, esfRaw.y, t=knots, k=3)
ysmooth = interpolate.splev(esfRaw.x, tck)
InterpDistances = np.linspace(esfRaw.x[0], esfRaw.x[-1], 500)
InterpValues = np.interp(InterpDistances, esfRaw.x, ysmooth)
esfInterp = cSet(InterpDistances, InterpValues)
if (verbose == Verbosity.BRIEF):
print("ESF Crop [done] (Distance from {0:2.2f} to {1:2.2f})".format(esfRaw.x[0], esfRaw.x[-1]))
elif (verbose == Verbosity.DETAIL):
x = [0, np.size(imgArr,1)-1]
y = np.polyval(finalEdgePoly, x)
fig = pylab.gcf()
fig.canvas.manager.set_window_title('ESF Crop')
(ax1, ax2) = plt.subplots(2)
ax1.imshow(imgArr, cmap='gray', vmin=0.0, vmax=1.0)
ax1.plot(x, y, color='red')
ax2.plot(esfRaw.x, esfRaw.y,InterpDistances,InterpValues)
plt.show(block=False)
plt.show()
return cESF(esfRaw, esfInterp, threshold, width, angle, edgePoly)
@staticmethod
def SimplifyEdgeSpreadFunction(esf, verbose=Verbosity.NONE):
"""
Remove dublicate distance occurances of Edge Spread Function.
...
Parameters
esf : cSet
ESF index-value set
verbose : Verbosity
Output verbosity level
...
Returns
cSet
Shrinked ESF index-value set
"""
res = np.unique(esf.x, return_index=True, return_counts=True)
indexes = res[1]
counts = res[2]
sz = np.size(res[0])
distances = esf.x[indexes]
values = np.zeros(sz, dtype=float)
for x in range(sz):
values[x] = np.sum(esf.y[indexes[x]:indexes[x]+counts[x]])/counts[x]
if (verbose == Verbosity.BRIEF):
print("ESF Simplification [done] (Size from {0:d} to {1:d})".format(np.size(esf.x), np.size(distances)))
elif (verbose == Verbosity.DETAIL):
fig = pylab.gcf()
fig.canvas.manager.set_window_title("ESF Simplification (Size from {0:d} to {1:d})".format(np.size(esf.x), np.size(distances)))
(ax1, ax2) = plt.subplots(2)
ax1.plot(esf.x, esf.y)
ax2.plot(distances, values)
plt.show(block=False)
plt.show()
return cSet(distances, values)
@staticmethod
def GetLineSpreadFunction(esf, normalize=True, verbose=Verbosity.NONE):
"""
Calculate Line Spread Function (LSF) from ESF
LSF is simply the derivative of ESF.
For a better result, an interpolated ESF can be used instead of raw ESF
...
Parameters
esf : cSet
ESF index-value set
normalize : bool
Calculated normalized LSF
verbose : Verbosity
Output verbosity level
...
Returns
cSet
LSF index-value set
"""
lsfDividend = np.diff(esf.y)
lsfDivisor = np.diff(esf.x)
lsfValues = np.divide(lsfDividend, lsfDivisor)
lsfDistances = esf.x[0:-1]
if normalize:
lsfValues = lsfValues / (max(lsfValues))
if (verbose == Verbosity.BRIEF):
print("MTF [done]")
elif (verbose == Verbosity.DETAIL):
fig = pylab.gcf()
fig.canvas.manager.set_window_title("LSF")
(ax1) = plt.subplots(1)
ax1.plot(lsfDistances, lsfValues)
plt.show(block=False)
plt.show()
return cSet(lsfDistances, lsfValues)
@staticmethod
def GetMTF(lsf, pixel_size_mm: float | None = None, verbose=Verbosity.NONE):
"""
Calculate Modulation Transform Function (MTF) from LSF
...
Parameters
lsf : cSet
LSF index-value set
pixel_size_mm : float | None
Pixel pitch in millimeters. If provided, the returned frequency
axis will be spatial frequency (line pairs/mm). If None, the
axis will be normalized to Nyquist (0..1 where 1=Nyquist=0.5 cyc/pixel).
verbose : Verbosity
Output verbosity level
...
Returns
cMTF
MTF value set except pixel transition width
"""
N = np.size(lsf.x)
# Sampling frequency in samples per pixel-distance unit
px = N/(lsf.x[-1]-lsf.x[0])
# Frequency bins in cycles per pixel-distance unit
distances = np.arange(0,N)/N*px
# MTF magnitude normalized to DC = 1
values = 1/np.sum(lsf.y)*abs(fft(lsf.y))
# Interpolate MTF to the range 0..1 cycles/pixel (1 = 2x Nyquist)
interpDistances_norm = np.linspace(0,1,50) # cycles/pixel
interp = interpolate.interp1d(distances, values, kind='cubic', fill_value='extrapolate')
interpValues = interp(interpDistances_norm)
# MTF at Nyquist (0.5 cycles/pixel)
valueAtNyquist = float(interp(0.5)) * 100.0
# Prepare axis: either normalized (Nyquist=1) or spatial frequency lp/mm
if pixel_size_mm is not None and pixel_size_mm > 0:
axis = interpDistances_norm / pixel_size_mm # lp/mm (since 1 cyc/pix = 1 lp/pix)
nyq_lpmm = 0.5 / pixel_size_mm
axis_label = f"Spatial frequency (lp/mm)"
title_suffix = f" (Nyquist: {nyq_lpmm:0.2f} lp/mm)"
else:
# Normalize such that Nyquist (0.5 cyc/pixel) maps to 1.0
axis = interpDistances_norm / 0.5 # 0..2 where Nyquist=1
axis_label = "Normalized frequency (Nyquist = 1)"
title_suffix = ""
if (verbose == Verbosity.BRIEF):
print("MTF [done]")
elif (verbose == Verbosity.DETAIL):
fig = pylab.gcf()
fig.canvas.manager.set_window_title(f"MTF ({valueAtNyquist:2.2f}% at Nyquist){title_suffix}")
(ax1) = plt.subplots(1)
ax1.plot(axis, interpValues)
ax1.set_xlabel(axis_label)
ax1.grid(True)
plt.show(block=False)
plt.show()
return cMTF(axis, interpValues, valueAtNyquist, -1.0)
@staticmethod
def CalculateMtf(imgArr,image_high=480,x_unit='Cycles/pixel', x_range=None,pixel_size_mm: float | None = None, verbose=Verbosity.NONE):
"""
Calculate Modulation Transform Function (MTF) of an image array
...
Parameters
imgArr : np.darray
Image data as numpy array having values between 0.0 and 1.0
pixel_size_mm : float | None
Pixel pitch in millimeters. If provided, the MTF plot x-axis will
be spatial frequency (lp/mm) instead of normalized frequency.
verbose : Verbosity
Output verbosity level
...
Returns
cMTF
MTF value set
"""
imgArr = Helper.CorrectImageOrientation(imgArr)
esf = MTF.GetEdgeSpreadFunctionCrop(imgArr, Verbosity.NONE)
lsf = MTF.GetLineSpreadFunction(esf.interpESF, True, Verbosity.NONE)
mtf = MTF.GetMTF(lsf, pixel_size_mm=pixel_size_mm, verbose=Verbosity.NONE)
temp_lsf = np.gradient(esf.interpESF.y)
# 直接使用已计算好的 MTF 结果,避免与频率轴长度不一致
# 根据奈奎斯特定理,仅保留不超过奈奎斯特频率的部分
if pixel_size_mm and pixel_size_mm > 0:
nyquist = 0.5 / pixel_size_mm # lp/mm
mask = mtf.x <= nyquist
else:
nyquist = 1.0 # 归一化频率轴下 Nyquist=1
mask = mtf.x <= nyquist
# mtf_x = mtf.x[mask]
# mtf_y = mtf.y[mask]
mtf_x = mtf.x
mtf_y = mtf.y
# 只向绘图函数传入可绘制的一维数组(y 值),避免传入 cSet 对象
plot_results(imgArr, esf.interpESF.y, mtf_x, mtf_y, lsf.y, image_high = image_high,x_range=x_range,x_unit=x_unit,
pixel_size=(pixel_size_mm if pixel_size_mm else 1.0))
# if (verbose == Verbosity.BRIEF):
# print("MTF at Nyquist:{0:0.2f}%, Transition Width:{1:0.2f}".format(mtf.mtfAtNyquist, esf.width))
#
# elif (verbose == Verbosity.DETAIL):
# x = [0, np.size(imgArr,1)-1]
# y = np.polyval(esf.edgePoly, x)
#
# fig = pylab.gcf()
# fig.canvas.manager.set_window_title('MTF Analysis')
# gs = fig.add_gridspec(3,2)
# ax1 = fig.add_subplot(gs[0, 0])
# ax2 = fig.add_subplot(gs[1, 0])
# ax3 = fig.add_subplot(gs[2, 0])
# ax4 = fig.add_subplot(gs[:, 1])
#
# ax1.imshow(imgArr, cmap='gray', vmin=0.0, vmax=1.0)
# ax1.plot(x, y, color='red')
# ax1.axis('off')
# ax2.plot(esf.rawESF.x, esf.rawESF.y,
# esf.interpESF.x, esf.interpESF.y)
# top = np.max(esf.rawESF.y)-esf.threshold
# bot = np.min(esf.rawESF.y)+esf.threshold
# ax2.plot([esf.rawESF.x[0], esf.rawESF.x[-1]], [top, top], color='red')
# ax2.plot([esf.rawESF.x[0], esf.rawESF.x[-1]], [bot, bot], color='red')
# ax2.xaxis.set_visible(False)
# ax2.yaxis.set_visible(False)
# ax3.plot(lsf.x, lsf.y)
# ax3.xaxis.set_visible(False)
# ax3.yaxis.set_visible(False)
# ax4.plot(mtf.x, mtf.y)
# # print(mtf.x)
# # Update title and label according to frequency axis type
# if pixel_size_mm is not None and pixel_size_mm > 0:
# nyq_lpmm = 0.5 / pixel_size_mm
# ax4.set_title("MTF at Nyquist:{0:0.2f}%\nTransition Width:{1:0.2f}\nNyquist:{2:0.2f} lp/mm".format(mtf.mtfAtNyquist, esf.width, nyq_lpmm))
# ax4.set_xlabel("Spatial frequency (lp/mm)")
# else:
# ax4.set_title("MTF at Nyquist:{0:0.2f}%\nTransition Width:{1:0.2f}".format(mtf.mtfAtNyquist, esf.width))
# ax4.set_xlabel("Normalized frequency (Nyquist = 1)")
# ax4.grid(True)
# plt.show(block=False)
# plt.show()
# Attach pixel transition width and return consistent data
mtf.width = esf.width
return mtf