//  [python]
def apply(self, img):
        """
        Method for performing Tikhinov reconstruction.

        Parameters
        ----------
        img : :py:class:`~numpy.ndarray` or :py:class:`torch.Tensor`
            Lensless capture measurement. Must be 3D even if grayscale.

        Returns
        -------
        :py:class:`~numpy.ndarray` or :py:class:`~torch.Tensor`
            Reconstructed image, in the same format as the measurement.
        """
        assert (
            len(img.shape) == 3
        ), "Object should be a 3D array or tensor (HxWxC) even if grayscale."

        if torch_available and isinstance(img, torch.Tensor):

            # Empty matrix for reconstruction
            n_channels = img.shape[-1]
            x_est = torch.empty([self.P.shape[1], self.Q.shape[1], n_channels])

            self.P = torch.from_numpy(self.P).float()
            self.Q = torch.from_numpy(self.Q).float()

            # Applying reconstruction for each channel
            for c in range(n_channels):
                Yc = img[:, :, c]

                # SVD of left matrix
                UL, SL, VLh = torch.svd(self.P)
                VL = VLh.T
                DL = torch.cat(
                    (
                        torch.diag(SL),
                        torch.zeros([self.P.shape[0] - SL.size(0), SL.size(0)], device=SL.device),
                    )
                )
                singLsq = SL**2

                # SVD of right matrix
                UR, SR, VRh = torch.svd(self.Q)
                VR = VRh.T
                DR = torch.cat(
                    (
                        torch.diag(SR),
                        torch.zeros([self.Q.shape[0] - SR.size(0), SR.size(0)], device=SR.device),
                    )
                )
                singRsq = SR**2

                # Applying analytical reconstruction
                inner = torch.mm(torch.mm(DL.T, UL.T), torch.mm(Yc, torch.mm(UR, DR))) / (
                    torch.outer(singLsq, singRsq) + torch.full(x_est.shape[0:2], self.lmbd)
                )
                x_est[:, :, c] = torch.mm(torch.mm(VL, inner), VR.T)

            # Non-negativity constraint: setting all negative values to 0
            x_est = torch.clamp(x_est, min=0)

            # Normalizing the image
            x_est = (x_est - x_est.min()) / (x_est.max() - x_est.min())

        else:

            # Empty matrix for reconstruction
            n_channels = img.shape[-1]
            x_est = np.empty([self.P.shape[1], self.Q.shape[1], n_channels])

            # Applying reconstruction for each channel
            for c in range(n_channels):

                # SVD of left matrix
                UL, SL, VLh = np.linalg.svd(self.P, full_matrices=True)
                VL = VLh.T
                DL = np.concatenate((np.diag(SL), np.zeros([self.P.shape[0] - SL.size, SL.size])))
                singLsq = np.square(SL)

                # SVD of right matrix
                UR, SR, VRh = np.linalg.svd(self.Q, full_matrices=True)
                VR = VRh.T
                DR = np.concatenate((np.diag(SR), np.zeros([self.Q.shape[0] - SR.size, SR.size])))
                singRsq = np.square(SR)

                # Applying analytical reconstruction
                Yc = img[:, :, c]
                inner = multi_dot([DL.T, UL.T, Yc, UR, DR]) / (
                    np.outer(singLsq, singRsq) + np.full(x_est.shape[0:2], self.lmbd)
                )
                x_est[:, :, c] = multi_dot([VL, inner, VR.T])

            # Non-negativity constraint: setting all negative values to 0
            x_est = x_est.clip(min=0)

            # Normalizing the image
            x_est = (x_est - x_est.min()) / (x_est.max() - x_est.min())

        return x_est