Cooke Triplet with Stock Lensesο
Hemkumar Srinivas - May 2025
π Overviewο
In this notebook, we design a Cooke Triplet β a classic three-element photographic lens known for correcting spherical, chromatic, and coma aberrations. Our goal is to construct a working prototype using off-the-shelf catalog lenses from vendors such as Thorlabs and Edmund Optics.
[1]:
import optiland.backend as be
from optiland import analysis, mtf, optic, psf, wavefront
from optiland import optimization
π Simulating an Object at Infinityο
To simulate an object located at infinity, we set the aperture type to 'EPD' (Entrance Pupil Diameter). This configuration sends a bundle of collimated rays through the optical system, which mimics incoming light from a distant source.
[2]:
lens = optic.Optic()
# add aperture
lens.set_aperture(aperture_type="EPD", value=10)
πΉ First Lens β Best-Form Convex (Thorlabs LBF254-040-A)ο
We begin with a best-form positive lens from Thorlabs β model LBF254-040-A. This lens is used as the front element of the Cooke Triplet to converge the incoming rays.
[3]:
# first lens - best form convex lens
lens.surfaces.add(index=0, radius=be.inf, thickness=be.inf)
lens.surfaces.add(index=1, radius=24.02, thickness=6.5, material="N-BK7")
lens.surfaces.add(index=2, radius=-134.6, thickness=10)
πΉ Second Lens β Bi-Concave (Edmund Optics #45-929)ο
The middle element of the Cooke Triplet is a negative (bi-concave) lens. Weβll use Edmund Optics lens #45-929, made of N-SF11 flint glass.
Since the optical stop is typically located within or near the second lens in this design, weβll place the stop on the second surface of this lens.
[4]:
lens.surfaces.add(index=3, radius=-31.82, thickness=2, material="N-SF11")
lens.surfaces.add(index=4, radius=31.82, thickness=10, is_stop=True)
πΉ Third Lens β Best-Form Convex (Again Thorlabs LBF254-040-A)ο
To complete the triplet, we reuse the same best-form convex lens (Thorlabs LBF254-040-A) as the rear positive element. This symmetric placement helps correct off-axis aberrations.
[5]:
lens.surfaces.add(index=5, radius=134.6, thickness=6.5, material="N-BK7")
lens.surfaces.add(index=6, radius=-24.02, thickness=50)
[6]:
# define surface for the image plane
lens.surfaces.add(index=7)
Now, letβs define the fields and wavelengths over which we would like to analyse the system.
[7]:
# add field
lens.fields.set_type(field_type="angle")
lens.fields.add(y=0)
lens.fields.add(y=5)
lens.fields.add(y=10)
# add wavelength
lens.wavelengths.add(value=0.48)
lens.wavelengths.add(value=0.55, is_primary=True)
lens.wavelengths.add(value=0.65)
Letβs see how the raytrace looks like.
[8]:
lens.draw()
Looks like we are not far away from the optimal configuratuion. We will now use the optimizer to find the best distances between the lenses. So, we define the optimization problem and the operands as follows. The system will be optimized at 550 nm wavelength.
[9]:
problem = optimization.OptimizationProblem()
input_data = {
"optic": lens,
"surface_number": -1,
"Hx": 0,
"Hy": 0,
"num_rays": 5,
"wavelength": 0.55,
"distribution": "hexapolar",
}
# add RMS spot size operand
problem.add_operand(
operand_type="rms_spot_size",
target=0,
weight=1,
input_data=input_data,
)
[10]:
problem.add_variable(lens, "thickness", surface_number=2)
problem.add_variable(lens, "thickness", surface_number=4)
problem.add_variable(lens, "thickness", surface_number=6)
Define optimizer and run
[11]:
optimizer = optimization.OptimizerGeneric(problem)
optimizer.optimize()
[11]:
message: CONVERGENCE: NORM_OF_PROJECTED_GRADIENT_<=_PGTOL
success: True
status: 0
fun: 0.00029382874404814514
x: [ 2.836e-01 1.297e-02 4.061e+00]
nit: 2
jac: [ 2.051e-05 5.413e-05 -4.469e-05]
nfev: 16
njev: 4
hess_inv: <3x3 LbfgsInvHessProduct with dtype=float64>
Display results
[12]:
problem.info()
ββββββ€βββββββββββββββββββββββββ€ββββββββββββββββββββ
β β Merit Function Value β Improvement (%) β
ββββββͺβββββββββββββββββββββββββͺββββββββββββββββββββ‘
β 0 β 0.000293829 β 99.9225 β
ββββββ§βββββββββββββββββββββββββ§ββββββββββββββββββββ
ββββββ€βββββββββββββββββ€βββββββββββ€βββββββββββββββ€βββββββββββββββ€βββββββββββ€ββββββββββ€ββββββββββ€βββββββββββββββββ
β β Operand Type β Target β Min. Bound β Max. Bound β Weight β Value β Delta β Contrib. [%] β
ββββββͺβββββββββββββββββͺβββββββββββͺβββββββββββββββͺβββββββββββββββͺβββββββββββͺββββββββββͺββββββββββͺβββββββββββββββββ‘
β 0 β rms spot size β 0 β β β 1 β 0.017 β 0.017 β 100 β
ββββββ§βββββββββββββββββ§βββββββββββ§βββββββββββββββ§βββββββββββββββ§βββββββββββ§ββββββββββ§ββββββββββ§βββββββββββββββββ
ββββββ€ββββββββββββββββββ€ββββββββββββ€ββββββββββ€βββββββββββββββ€βββββββββββββββ
β β Variable Type β Surface β Value β Min. Bound β Max. Bound β
ββββββͺββββββββββββββββββͺββββββββββββͺββββββββββͺβββββββββββββββͺβββββββββββββββ‘
β 0 β thickness β 2 β 12.8359 β β β
β 1 β thickness β 4 β 10.1297 β β β
β 2 β thickness β 6 β 50.6111 β β β
ββββββ§ββββββββββββββββββ§ββββββββββββ§ββββββββββ§βββββββββββββββ§βββββββββββββββ
Draw the final lens
[13]:
lens.draw()
Now, having optimized the distances between the lenses, we can look at the performance of the system :
[14]:
spot = analysis.SpotDiagram(lens)
spot.view()
[15]:
distortion = analysis.Distortion(lens)
distortion.view()
[16]:
grid = analysis.GridDistortion(lens)
grid.view()
[17]:
geo_mtf = mtf.GeometricMTF(lens)
geo_mtf.view()
To conclude, we have been able to construct a Cooke Triplet using simple off-the-shelf lenses. Together, these lenses could be mounted on cage systems from Thorlabs. The system seems to perform reasonably over a narrow field of view (about 5 degrees or less) and after this, it exhibits noticeable distortion. Nonetheless, this could be a quick DIY solution for some imaging needs in a lab (eg. Relay lens for a microscope, a compact projection system etc.)