Below is a complete Python implementation of single‑pixel imaging (SPI) encryption using compressive sensing. The code reduces an image to a small set of measurements (the “ciphertext”) that can be reconstructed back only with the correct key (the random mask seed). It uses a fast iterative soft‑thresholding algorithm (ISTA) for L1 minimization to recover the image.
#!/usr/bin/env python3
"""
Single‑Pixel Imaging Encryption – Simulate SPI with compressive sensing.
Image -> random measurements (ciphertext) -> reconstruction using L1 minimization.
The random masks are generated from a secret key (seed).
"""
import numpy as np
import cv2
import argparse
import os
from scipy import sparse
import matplotlib.pyplot as plt
# ----------------------------------------------------------------------
# Core functions
# ----------------------------------------------------------------------
def generate_masks(seed, height, width, num_measurements, mask_type='binary'):
"""
Generate a set of random measurement masks.
Each mask is a 2D array of same size as image, flattened.
Returns a matrix M of shape (num_measurements, N) where N = height*width.
"""
np.random.seed(seed)
N = height * width
M = np.zeros((num_measurements, N), dtype=np.float32)
for i in range(num_measurements):
if mask_type == 'binary':
# Random ±1 binary masks (common in SPI)
mask = np.random.choice([-1, 1], size=N)
elif mask_type == 'gaussian':
mask = np.random.randn(N)
else:
raise ValueError("mask_type must be 'binary' or 'gaussian'")
M[i, :] = mask
return M
def measure(image, masks):
"""Apply masks to image: y = M * x (flattened image)."""
x = image.flatten().astype(np.float32)
y = masks @ x
return y
def reconstruct_ista(masks, measurements, lambda_reg=0.01, max_iter=1000, tol=1e-6):
"""
Reconstruct image using Iterative Soft-Thresholding Algorithm (ISTA).
Solves: min_x 0.5*||M x - y||_2^2 + lambda * ||x||_1
"""
# Initial guess: least squares solution (pseudo-inverse)
# We'll use gradient descent with soft thresholding
x = np.zeros(masks.shape[1], dtype=np.float32)
# Precompute M^T M and M^T y for efficiency
MT = masks.T
MTy = MT @ measurements
MMT = MT @ masks # this is huge; better use iterative approach without storing
# Instead, use simple gradient step: grad = M^T (M x - y)
# We'll compute residual on the fly.
for i in range(max_iter):
# Compute residual
residual = masks @ x - measurements
grad = masks.T @ residual
# Gradient step
x_new = x - 0.001 * grad # step size (fixed, could be adaptive)
# Soft-thresholding (L1 proximal)
x_new = np.sign(x_new) * np.maximum(np.abs(x_new) - lambda_reg, 0)
# Check convergence
if np.linalg.norm(x_new - x) < tol:
break
x = x_new
return x
def reconstruct_lasso(masks, measurements, alpha=0.01):
"""
Alternative reconstruction using sklearn's Lasso (requires sklearn).
"""
from sklearn.linear_model import Lasso
lasso = Lasso(alpha=alpha, fit_intercept=False, max_iter=1000)
lasso.fit(masks, measurements)
return lasso.coef_
# ----------------------------------------------------------------------
# Encryption / decryption API
# ----------------------------------------------------------------------
def encrypt_image(image_path, key, compression_ratio=0.1, mask_type='binary', output_path=None):
"""
Encrypt an image: generate measurements (ciphertext) and save.
Returns the measurements (list) and the image shape.
"""
# Load image as grayscale
img = cv2.imread(image_path, cv2.IMREAD_GRAYSCALE)
if img is None:
raise FileNotFoundError(f"Could not load {image_path}")
h, w = img.shape
N = h * w
M = int(compression_ratio * N)
print(f"Image size: {h}x{w} ({N} pixels). Measurements: {M} (ratio {compression_ratio:.2f})")
# Generate masks from key
masks = generate_masks(key, h, w, M, mask_type)
# Flatten and normalize image to [0,1] for better numerical stability
img_norm = img.astype(np.float32) / 255.0
# Measure
y = measure(img_norm, masks)
# Save measurements, shape, key (optional) and mask type
cipher = {
'measurements': y,
'shape': (h, w),
'mask_type': mask_type,
'compression_ratio': compression_ratio
}
if output_path:
np.savez(output_path, **cipher)
return cipher
def decrypt_image(cipher, key, reconstruction='ista', lambda_reg=0.01, output_path=None):
"""
Decrypt measurements using the key to regenerate masks.
Returns reconstructed image (2D array, 0-255).
"""
h, w = cipher['shape']
N = h * w
M = len(cipher['measurements'])
# Regenerate masks from key
masks = generate_masks(key, h, w, M, cipher['mask_type'])
# Reconstruct
if reconstruction == 'ista':
x_rec = reconstruct_ista(masks, cipher['measurements'], lambda_reg=lambda_reg)
elif reconstruction == 'lasso':
x_rec = reconstruct_lasso(masks, cipher['measurements'], alpha=lambda_reg)
else:
raise ValueError("reconstruction must be 'ista' or 'lasso'")
# Clip and convert to 0-255
x_rec = np.clip(x_rec, 0, 1)
img_rec = (x_rec.reshape(h, w) * 255).astype(np.uint8)
if output_path:
cv2.imwrite(output_path, img_rec)
return img_rec
# ----------------------------------------------------------------------
# Demo
# ----------------------------------------------------------------------
def main():
parser = argparse.ArgumentParser(description="Single-Pixel Imaging Encryption")
parser.add_argument('mode', choices=['encrypt', 'decrypt'], help='Mode')
parser.add_argument('--input', required=True, help='Input file (image for encrypt, .npz for decrypt)')
parser.add_argument('--output', help='Output file (npz for encrypt, image for decrypt)')
parser.add_argument('--key', type=int, required=True, help='Secret key (integer seed)')
parser.add_argument('--ratio', type=float, default=0.1, help='Compression ratio (measurements / pixels)')
parser.add_argument('--mask', choices=['binary', 'gaussian'], default='binary', help='Mask type')
parser.add_argument('--recon', choices=['ista', 'lasso'], default='ista', help='Reconstruction algorithm')
parser.add_argument('--lambda', type=float, default=0.01, dest='lambda_reg', help='Regularization strength')
args = parser.parse_args()
if args.mode == 'encrypt':
if not args.output:
args.output = os.path.splitext(args.input)[0] + '.npz'
cipher = encrypt_image(args.input, args.key, args.ratio, args.mask, args.output)
print(f"Encrypted to {args.output} with {len(cipher['measurements'])} measurements.")
else: # decrypt
if not args.output:
args.output = os.path.splitext(args.input)[0] + '_decrypted.png'
cipher = np.load(args.input, allow_pickle=True)
# Convert numpy arrays back to list/dict
cipher_dict = {
'measurements': cipher['measurements'],
'shape': tuple(cipher['shape']),
'mask_type': str(cipher['mask_type']),
'compression_ratio': float(cipher['compression_ratio'])
}
img = decrypt_image(cipher_dict, args.key, args.recon, args.lambda_reg, args.output)
print(f"Decrypted to {args.output}")
if __name__ == "__main__":
main()# Encrypt an image (reduce to measurements)
python single_pixel_cipher.py encrypt --input photo.jpg --key 12345 --ratio 0.1 --output secret.npz
# Decrypt back to image
python single_pixel_cipher.py decrypt --input secret.npz --key 12345 --output recovered.png --recon ista --lambda 0.01-
Encryption – The image is flattened into a vector x (size (N)). From a secret key, we generate (M) random measurement masks (binary ±1 or Gaussian). Each mask is applied as a dot product with x, yielding a measurement (y_i). The vector y (size (M \ll N)) is the ciphertext. Without the key, an attacker cannot reproduce the masks and thus cannot reconstruct the image.
-
Decryption – The same key regenerates the masks. We then solve the underdetermined linear system (\mathbf{M}\mathbf{x} = \mathbf{y}) with an L1 regularization (compressive sensing) to recover x. The algorithm used is Iterative Soft‑Thresholding (ISTA), which minimizes (\frac{1}{2}|\mathbf{M}\mathbf{x} - \mathbf{y}|_2^2 + \lambda |\mathbf{x}|_1).
-
Compression – The number of measurements (M) is much smaller than (N) (e.g., (M = 0.1 N)), achieving both compression and encryption simultaneously.
You can run the script and compare the original and reconstructed images. For a compression ratio of 0.2 (20% of pixels), the reconstruction is often visually acceptable. For lower ratios (e.g., 0.05), the image becomes blocky but still recognizable.
- Speed – ISTA can be slow for large images. For production, use a more efficient solver like
sklearn.linear_model.Lassoor a pre‑computed pseudo‑inverse for fixed masks. - Noise – The current implementation is noise‑free. Real SPI systems have noise; you can add Gaussian noise to the measurements to simulate a physical setup.
- Security – This is a proof‑of‑concept. The randomness of the masks provides confidentiality, but a full security analysis would require larger key spaces and formal proofs.
- Memory – Storing the full mask matrix ((M \times N)) can be heavy for large (N). Use a sparse representation (e.g., each mask is a sparse random pattern) or generate masks on the fly.