# #############################################################################
# fabrication.py
# =================
# Authors :
# Rein BENTDAL [rein.bent@gmail.com]
# Eric BEZZAM [ebezzam@gmail.com]
# #############################################################################
"""
Mask Fabrication
================
This module provides tools for fabricating masks for lensless imaging.
Check out `this notebook <https://colab.research.google.com/drive/1eDLnDL5q4i41xPZLn73wKcKpZksfkkIo?usp=drive_link>`_ on Google Colab for how to use this module.
"""
import os
import cadquery as cq
import numpy as np
from typing import Union, Optional
from abc import ABC, abstractmethod
from lensless.hardware.mask import Mask, MultiLensArray, CodedAperture, FresnelZoneAperture
class Frame(ABC):
@abstractmethod
def generate(self, mask_size, depth: float) -> cq.Workplane:
pass
class Connection(ABC):
@abstractmethod
def generate(self, mask: np.ndarray, mask_size, depth: float) -> cq.Workplane:
"""connections can in general use the mask array to determine where to connect to the mask, but it is not required."""
pass
[docs]class Mask3DModel:
[docs] def __init__(
self,
mask_array: np.ndarray,
mask_size: Union[tuple[float, float], np.ndarray],
height: Optional[float] = None,
frame: Optional[Frame] = None,
connection: Optional[Connection] = None,
simplify: bool = False,
show_axis: bool = False,
generate: bool = True,
):
"""
Wrapper to CadQuery to generate a 3D model from a mask array, e.g. for 3D printing.
Parameters
----------
mask_array : np.ndarray
Array of the mask to generate from. 1 is opaque, 0 is transparent.
mask_size : Union[tuple[float, float], np.ndarray]
Dimensions of the mask in meters.
height : Optional[float], optional
How thick to make the mask in millimeters.
frame : Optional[Frame], optional
Frame object defining the frame around the mask.
connection : Optional[Connection], optional
Connection object defining how to connect the frame to the mask.
simplify : bool, optional
Combines all objects in the model to a single object. Can result in a smaller 3d model file and faster post processing. But takes a considerable amount of more time to generate model. Defaults to False.
show_axis : bool, optional
Show axis for debug purposes. Defaults to False.
generate : bool, optional
Generate model on initialization. Defaults to True.
"""
self.mask = mask_array
self.frame: Frame = frame
self.connections: Connection = connection
if isinstance(mask_size, tuple):
self.mask_size = np.array(mask_size) * 1e3
else:
self.mask_size = mask_size * 1e3
self.height = height
self.simplify = simplify
self.show_axis = show_axis
self.model = None
if generate:
self.generate_3d_model()
[docs] @classmethod
def from_mask(cls, mask: Mask, **kwargs):
"""
Create a Mask3DModel from a Mask object.
Parameters
----------
mask : :py:class:`~lensless.hardware.mask.Mask`
Mask object to generate from, e.g. :py:class:`~lensless.hardware.mask.CodedAperture` or :py:class:`~lensless.hardware.mask.FresnelZoneAperture`.
"""
assert isinstance(mask, CodedAperture) or isinstance(
mask, FresnelZoneAperture
), "Mask must be a CodedAperture or FresnelZoneAperture object."
return cls(mask_array=mask.mask, mask_size=mask.size, **kwargs)
[docs] @staticmethod
def mask_to_points(mask: np.ndarray, px_size: Union[tuple[float, float], np.ndarray]):
"""
Turns mask into 2D point coordinates.
Parameters
----------
mask : np.ndarray
Mask array.
px_size : Union[tuple[float, float], np.ndarray]
Pixel size in meters.
"""
is_3D = len(np.unique(mask)) > 2
if is_3D:
indices = np.argwhere(mask != 0)
coordinates = (indices - np.array(mask.shape) / 2) * px_size
heights = mask[indices[:, 0], indices[:, 1]]
else:
indices = np.argwhere(mask == 0)
coordinates = (indices - np.array(mask.shape) / 2) * px_size
heights = None
return coordinates, heights
[docs] def generate_3d_model(self):
"""
Based on provided (1) mask, (2) frame, and (3) connection between frame and mask, generate a 3d model.
"""
assert self.model is None, "Model already generated."
model = cq.Workplane("XY")
if self.frame is not None:
frame_model = self.frame.generate(self.mask_size, self.height)
model = model.add(frame_model)
if self.connections is not None:
connection_model = self.connections.generate(self.mask, self.mask_size, self.height)
model = model.add(connection_model)
px_size = self.mask_size / self.mask.shape
points, heights = Mask3DModel.mask_to_points(self.mask, px_size)
if len(points) != 0:
if heights is None:
assert self.height is not None, "height must be provided if mask is 2D."
mask_model = (
cq.Workplane("XY")
.pushPoints(points)
.box(px_size[0], px_size[1], self.height, centered=False, combine=False)
)
else:
mask_model = cq.Workplane("XY")
for point, height in zip(points, heights):
box = (
cq.Workplane("XY")
.moveTo(point[0], point[1])
.box(
px_size[0],
px_size[1],
height * self.height,
centered=False,
combine=False,
)
)
mask_model = mask_model.add(box)
if self.simplify:
mask_model = mask_model.combine(glue=True)
model = model.add(mask_model)
if self.simplify:
model = model.combine(glue=False)
if self.show_axis:
axis_thickness = 0.01
axis_length = 20
axis_test = (
cq.Workplane("XY")
.box(axis_thickness, axis_thickness, axis_length)
.box(axis_thickness, axis_length, axis_thickness)
.box(axis_length, axis_thickness, axis_thickness)
)
model = model.add(axis_test)
self.model = model
[docs] def save(self, fname):
"""
Save the 3d model to a file.
Parameters
----------
fname : str
File name to save the model to.
"""
assert self.model is not None, "Model not generated yet."
directory = os.path.dirname(fname)
if directory and not os.path.exists(directory):
print(
f"Error: The directory {directory} does not exist! Failed to save CadQuery model."
)
return
cq.exporters.export(self.model, fname)
[docs]class MultiLensMold:
[docs] def __init__(
self,
sphere_locations: np.ndarray,
sphere_radius: np.ndarray,
mask_size: Union[tuple[float, float], np.ndarray],
mold_size: tuple[int, int, int] = (0.4e-1, 0.4e-1, 3.0e-3),
base_height_mm: Optional[float] = 0.5,
frame: Optional[Frame] = None,
simplify: bool = False,
show_axis: bool = False,
):
"""
Create a 3D model of a multi-lens array mold.
Parameters
----------
sphere_locations : np.ndarray
Array of sphere locations in meters.
sphere_radius : np.ndarray
Array of sphere radii in meters.
mask_size : Union[tuple[float, float], np.ndarray]
Dimensions of the mask in meters.
mold_size : tuple[int, int, int], optional
Dimensions of the mold in meters. Defaults to (0.4e-1, 0.4e-1, 3.0e-3).
base_height_mm : Optional[float], optional
Height of the base in millimeters. Defaults to 0.5.
frame : Optional[Frame], optional
Frame object defining the frame around the mask.
simplify : bool, optional
Combines all objects in the model to a single object. Can result in a smaller 3d model file and faster post processing. But takes a considerable amount of more time to generate model. Defaults to False.
show_axis : bool, optional
Show axis for debug purposes. Defaults to False.
"""
self.mask_size_mm = mask_size * 1e3
self.mold_size_mm = np.array(mold_size) * 1e3
self.simplify = simplify
self.frame = frame
self.show_axis = show_axis
self.n_lens = len(sphere_radius)
# check mold larger than mask
assert np.all(self.mask_size_mm <= self.mold_size_mm[:2]), "Mold must be larger than mask."
assert base_height_mm < self.mold_size_mm[2], "Base height must be less than mold height."
# create 3D model of multi-lens array
model = cq.Workplane("XY")
base_model = cq.Workplane("XY").box(
self.mask_size_mm[0], self.mask_size_mm[1], base_height_mm, centered=(True, True, False)
)
model = model.add(base_model)
if self.frame is not None:
frame_model = self.frame.generate(self.mask_size_mm, base_height_mm)
model = model.add(frame_model)
sphere_model = cq.Workplane("XY")
for i in range(self.n_lens):
loc_mm = sphere_locations[i] * 1e3
# # center locations
loc_mm[0] -= self.mask_size_mm[1] / 2
loc_mm[1] -= self.mask_size_mm[0] / 2
r_mm = sphere_radius[i] * 1e3
sphere = cq.Workplane("XY").moveTo(loc_mm[1], loc_mm[0]).sphere(r_mm, angle1=0)
sphere_model = sphere_model.add(sphere)
# add indent for removing
if self.frame is not None:
mask_dim = self.frame.size
else:
mask_dim = self.mask_size_mm
# indent = cq.Workplane("XY").moveTo(0, mask_dim[1] / 2).sphere(base_height_mm, angle1=0)
# indent = indent.translate((0, 0, -base_height_mm))
indent = (
cq.Workplane("XY")
.moveTo(0, mask_dim[1] / 2)
.box(base_height_mm, base_height_mm, base_height_mm)
)
indent = indent.translate((0, 0, -base_height_mm / 2))
sphere_model = sphere_model.add(indent)
# add to base
sphere_model = sphere_model.translate((0, 0, base_height_mm))
model = model.add(sphere_model)
if self.simplify:
model = model.combine(glue=True)
if self.show_axis:
axis_thickness = 0.01
axis_length = 20
axis_test = (
cq.Workplane("XY")
.box(axis_thickness, axis_thickness, axis_length)
.box(axis_thickness, axis_length, axis_thickness)
.box(axis_length, axis_thickness, axis_thickness)
)
model = model.add(axis_test)
self.mask = model
# create mold
mold = cq.Workplane("XY").box(
self.mold_size_mm[0],
self.mold_size_mm[1],
self.mold_size_mm[2],
centered=(True, True, False),
)
mold = mold.cut(model).rotate((0, 0, 0), (1, 0, 0), 180)
self.mold = mold
[docs] @classmethod
def from_mask(cls, mask: Mask, **kwargs):
"""
Create a Mask3DModel from a Mask object.
Parameters
----------
mask : :py:class:`~lensless.hardware.mask.MultiLensArray`
Multi-lens array mask object.
"""
assert isinstance(mask, MultiLensArray), "Mask must be a MultiLensArray object."
return cls(
sphere_locations=mask.loc, sphere_radius=mask.radius, mask_size=mask.size, **kwargs
)
def save(self, fname):
assert self.mold is not None, "Model not generated yet."
directory = os.path.dirname(fname)
if directory and not os.path.exists(directory):
print(
f"Error: The directory {directory} does not exist! Failed to save CadQuery model."
)
return
cq.exporters.export(self.mold, fname)
# --- from here, implementations of frames and connections ---
class SimpleFrame(Frame):
def __init__(self, padding: float = 2, size: Optional[tuple[float, float]] = None):
"""
Specify either padding or size. If size is specified, padding is ignored.
All dimensions are in millimeters.
Parameters
----------
padding : float, optional
padding around the mask. Defaults to 2mm.
size : Optional[tuple[float, float]], optional
Size of the frame in mm. Defaults to None.
"""
self.padding = padding
self.size = size
def generate(self, mask_size, depth: float) -> cq.Workplane:
width, height = mask_size[0], mask_size[1]
size = (
self.size
if self.size is not None
else (width + 2 * self.padding, height + 2 * self.padding)
)
return (
cq.Workplane("XY")
.box(size[0], size[1], depth, centered=(True, True, False))
.rect(width, height)
.cutThruAll()
)
class CrossConnection(Connection):
"""Transverse cross connection"""
def __init__(self, line_width: float = 0.1, mask_radius: float = None):
self.line_width = line_width
self.mask_radius = mask_radius
def generate(self, mask: np.ndarray, mask_size, depth: float) -> cq.Workplane:
width, height = mask_size[0], mask_size[1]
model = (
cq.Workplane("XY")
.box(self.line_width, height, depth, centered=(True, True, False))
.box(width, self.line_width, depth, centered=(True, True, True))
)
if self.mask_radius is not None:
circle = cq.Workplane("XY").cylinder(
depth, self.mask_radius, centered=(True, True, False)
)
model = model.cut(circle)
return model
class SaltireConnection(Connection):
"""Diagonal cross connection"""
def __init__(self, line_width: float = 0.1, mask_radius: float = None):
self.line_width = line_width
self.mask_radius = mask_radius
def generate(self, mask: np.ndarray, mask_size, depth: float) -> cq.Workplane:
width, height = mask_size[0], mask_size[1]
width2, height2 = width / 2, height / 2
lw = self.line_width / np.sqrt(2)
model = (
cq.Workplane("XY")
.moveTo(-(width2 - lw), -height2)
.lineTo(-width2, -height2)
.lineTo(-width2, -(height2 - lw))
.lineTo(width2 - lw, height2)
.lineTo(width2, height2)
.lineTo(width2, height2 - lw)
.close()
.extrude(depth)
.moveTo(-(width2 - lw), height2)
.lineTo(-width2, height2)
.lineTo(-width2, height2 - lw)
.lineTo(width2 - lw, -height2)
.lineTo(width2, -height2)
.lineTo(width2, -(height2 - lw))
.close()
.extrude(depth)
)
if self.mask_radius is not None:
circle = cq.Workplane("XY").cylinder(
depth, self.mask_radius, centered=(True, True, False)
)
model = model.cut(circle)
return model
class ThreePointConnection(Connection):
"""
Connection for free-floating components as in FresnelZoneAperture.
"""
def __init__(self, line_width: float = 0.1, mask_radius: float = None):
self.line_width = line_width
self.mask_radius = mask_radius
def generate(self, mask: np.ndarray, mask_size, depth: float) -> cq.Workplane:
width, height = mask_size[0], mask_size[1]
width2, height2 = width / 2, height / 2
lw = self.line_width / np.sqrt(2)
model = (
cq.Workplane("XY")
.box(width2, self.line_width, depth, centered=(False, True, False))
.moveTo(-(width2 - lw), -height2)
.lineTo(-width2, -height2)
.lineTo(-width2, -(height2 - lw))
.lineTo(-lw, 0)
.lineTo(lw, 0)
.close()
.extrude(depth)
.moveTo(-(width2 - lw), height2)
.lineTo(-width2, height2)
.lineTo(-width2, (height2 - lw))
.lineTo(-lw, 0)
.lineTo(lw, 0)
.close()
.extrude(depth)
)
if self.mask_radius is not None:
circle = cq.Workplane("XY").cylinder(
depth, self.mask_radius, centered=(True, True, False)
)
model = model.cut(circle)
return model
class CodedApertureConnection(Connection):
def __init__(self, joint_radius: float = 0.1):
self.joint_radius = joint_radius
def generate(self, mask: np.ndarray, mask_size, depth: float) -> cq.Workplane:
x_lines = np.where(np.diff(mask[:, 0]) != 0)[0] + 1
y_lines = np.where(np.diff(mask[0]) != 0)[0] + 1
X, Y = np.meshgrid(x_lines, y_lines)
point_idxs = np.vstack([X.ravel(), Y.ravel()]).T - np.array(mask.shape) / 2
px_size = mask_size / mask.shape
points = point_idxs * px_size
model = (
cq.Workplane("XY")
.pushPoints(points)
.cylinder(depth, self.joint_radius, centered=(True, True, False), combine=False)
)
return model
def create_mask_adapter(
fp, mask_w, mask_h, mask_d, adapter_w=12.90, adapter_h=9.90, support_w=0.4, support_d=0.4
):
"""
Create and store an adapter for a mask given its measurements.
Warning: Friction-fitted parts are to be made 0.05-0.1 mm smaller
(ex: mask's width must fit in adapter's, adapter's width must fit in mount's, ...)
Parameters
----------
fp : string
Folder in which to store the generated stl file.
mask_w : float
Length of the mask's width in mm.
mask_h : float
Length of the mask's height in mm.
mask_d : float
Thickness of the mask in mm.
adapter_w : float
Length of the adapter's width in mm.
default: current mount dim (13 - 0.1 mm)
adapter_h : float
Length of the adapter's height in mm.
default: current mount dim (1.5 - 0.1 mm)
support_w : float
Width of the small extrusion to support the mask in mm
default : current mount's dim (10 - 0.1 mm)
support_d : float
Thickness of the small extrusion to support the mask in mm
"""
epsilon = 0.2
# Make sure the dimension are realistic
assert mask_w < adapter_w - epsilon, "mask's width too big"
assert mask_h < adapter_h - epsilon, "mask's height too big"
assert mask_w - 2 * support_w > epsilon, "mask's support too big"
assert mask_h - 2 * support_w > epsilon, "mask's support too big"
assert os.path.exists(fp), "folder does not exist"
file_name = os.path.join(fp, "mask_adapter.stl")
# Prevent accidental overwrite
if os.path.isfile(file_name):
print("Warning: already find mask_adapter.stl at " + fp)
if input("Overwrite ? y/n") != "y":
print("Abort adapter generation.")
return
# Construct the outer layer of the mask
adapter = (
cq.Workplane("front")
.rect(adapter_w, adapter_h)
.rect(mask_w, mask_h)
.extrude(mask_d + support_d)
)
# Construct the dent to keep the mask secure
support = (
cq.Workplane("front")
.rect(mask_w, mask_h)
.rect(mask_w - 2 * support_w, mask_h - 2 * support_w)
.extrude(support_d)
)
# Join the 2 shape in one
adapter = adapter.union(support)
# Save into path
cq.exporters.export(adapter, file_name)