Skip to content

Instantly share code, notes, and snippets.

@benpeoples
Created May 23, 2026 11:31
Show Gist options
  • Select an option

  • Save benpeoples/dc7072faae4062b1c35cc2a1c04a6019 to your computer and use it in GitHub Desktop.

Select an option

Save benpeoples/dc7072faae4062b1c35cc2a1c04a6019 to your computer and use it in GitHub Desktop.
SIRDS Stereogram Extractor
"""
Stereogram Depth Map Extractor
================================
Extracts a depth map from a SIRDS (Single Image Random Dot Stereogram)
or textured stereogram by exploiting the repeating pattern.
How it works:
- Stereograms encode depth by shifting a repeating texture horizontally.
- The base "period" is the tile width. Objects closer to the viewer have
a smaller apparent period (the repeating pattern is compressed).
- We find the base period via autocorrelation, then compute per-pixel
disparity using a sliding cross-correlation window.
- Disparity → depth: smaller disparity = object is closer (in front).
Public domain as far as I know, written by Claude
"""
import numpy as np
from PIL import Image, ImageFilter
import argparse
import sys
from pathlib import Path
def to_grayscale(img: Image.Image) -> np.ndarray:
"""Convert PIL image to float32 grayscale array [0,1]."""
return np.array(img.convert("L"), dtype=np.float32) / 255.0
def find_period(gray: np.ndarray, search_range=(50, 250)) -> int:
"""
Estimate the base repeating period of the stereogram via row autocorrelation.
We average the autocorrelation over many rows to get a robust estimate.
The first strong peak after lag=0 is the tile period.
"""
h, w = gray.shape
# Use middle 60% of rows to avoid borders / watermark
rows = gray[int(h * 0.2):int(h * 0.8), :]
# Compute mean autocorrelation across rows
mean_acf = np.zeros(w)
for row in rows[::4]: # sample every 4 rows for speed
row_norm = row - row.mean()
acf = np.correlate(row_norm, row_norm, mode='full')
acf = acf[len(acf)//2:] # keep positive lags
acf /= acf[0] + 1e-9 # normalize
mean_acf += acf[:w]
mean_acf /= mean_acf[0]
# Find first prominent peak in the search range
lo, hi = search_range
hi = min(hi, w // 3)
region = mean_acf[lo:hi]
# Find peaks: local maxima
from scipy.signal import find_peaks
peaks, props = find_peaks(region, height=0.3, distance=20)
if len(peaks) == 0:
# fallback: argmax
period = int(np.argmax(region)) + lo
else:
period = int(peaks[0]) + lo
return period
def compute_disparity_map(gray: np.ndarray, period: int,
window_h: int = 9, window_w: int = 9,
max_shift: int = 30) -> np.ndarray:
"""
For each pixel, find the horizontal shift (disparity) that best aligns
a window centered here with the window shifted one period to the right.
In a stereogram:
- If there's no depth object, the shift equals exactly `period`.
- If depth is encoded, the apparent shift is period ± disparity.
We search around `period` for the best cross-correlation peak.
Returns a disparity map (float32), same shape as input.
"""
h, w = gray.shape
disparity = np.zeros((h, w), dtype=np.float32)
ph = window_h // 2
pw = window_w // 2
# Pad image horizontally for shifting
pad = max_shift + pw + period
padded = np.pad(gray, ((ph, ph), (pad, pad)), mode='edge')
offset = pad # column offset in padded array
for row in range(h):
pr = row + ph # row in padded image
for col in range(pw, w - pw):
pc = col + offset
# Reference window around (row, col)
ref = padded[pr-ph:pr+ph+1, pc-pw:pc+pw+1]
ref_norm = ref - ref.mean()
ref_std = ref_norm.std() + 1e-9
best_score = -np.inf
best_d = 0
# Search over shifts around the period
for d in range(-max_shift, max_shift + 1):
shift = period + d
if pc + shift - pw < 0 or pc + shift + pw + 1 > padded.shape[1]:
continue
candidate = padded[pr-ph:pr+ph+1,
pc+shift-pw:pc+shift+pw+1]
cand_norm = candidate - candidate.mean()
cand_std = cand_norm.std() + 1e-9
ncc = np.sum(ref_norm * cand_norm) / (ref_std * cand_std * ref_norm.size)
if ncc > best_score:
best_score = ncc
best_d = d
disparity[row, col] = best_d
return disparity
def compute_disparity_fast(gray: np.ndarray, period: int,
strip_width: int = 8,
max_shift: int = 35) -> np.ndarray:
"""
Faster column-strip based disparity computation.
Instead of per-pixel windows, we compare vertical strips of width
`strip_width` separated by `period + d` columns.
Returns disparity map (float32).
"""
h, w = gray.shape
disparity = np.full((h, w), np.nan, dtype=np.float32)
sw = strip_width
for col in range(0, w - period - max_shift - sw, sw // 2):
ref_strip = gray[:, col:col+sw] # left strip
scores = {}
for d in range(-max_shift, max_shift + 1):
right_col = col + period + d
if right_col < 0 or right_col + sw > w:
continue
cmp_strip = gray[:, right_col:right_col+sw]
# Row-wise NCC
diff = ref_strip - cmp_strip
scores[d] = -np.mean(diff**2, axis=1) # higher = better match
if not scores:
continue
# Stack scores: shape (n_shifts, h)
ds = sorted(scores.keys())
score_matrix = np.stack([scores[d] for d in ds], axis=0) # (n_shifts, h)
# For each row, pick best shift
best_idx = np.argmax(score_matrix, axis=0) # (h,)
best_d = np.array([ds[i] for i in best_idx], dtype=np.float32)
# Assign to columns covered by this strip
for c in range(col, min(col + sw, w)):
if c < w:
disparity[:, c] = best_d
# Fill any NaN edges
# Forward-fill columns
for c in range(1, w):
mask = np.isnan(disparity[:, c])
disparity[mask, c] = disparity[mask, c-1] if c > 0 else 0
disparity = np.nan_to_num(disparity, nan=0.0)
return disparity
def smooth_disparity(disparity: np.ndarray, sigma: int = 12) -> np.ndarray:
"""Apply Gaussian smoothing to reduce noise in disparity map."""
from scipy.ndimage import gaussian_filter
return gaussian_filter(disparity, sigma=sigma)
def disparity_to_depthmap(disparity: np.ndarray) -> np.ndarray:
"""
Convert disparity to a normalized 0–255 depth image.
Convention: brighter = closer to viewer (positive disparity = object pulled forward).
We invert so white = close, black = far (standard depth map convention).
"""
d = disparity.copy()
# Robust normalization: clip outliers
lo, hi = np.percentile(d, 2), np.percentile(d, 98)
if hi == lo:
hi = lo + 1
d = np.clip(d, lo, hi)
d = (d - lo) / (hi - lo)
# Invert: larger positive disparity = object is closer
# (In SIRDS, objects in front create smaller period → negative d)
# We flip so foreground appears bright
d = 1.0 - d
return (d * 255).astype(np.uint8)
def colorize_depthmap(depth_gray: np.ndarray) -> Image.Image:
"""Apply a perceptually-pleasing colormap (plasma-like) to the depth map."""
try:
import matplotlib.cm as cm
colormap = cm.get_cmap('plasma')
colored = colormap(depth_gray / 255.0)
return Image.fromarray((colored[:, :, :3] * 255).astype(np.uint8))
except ImportError:
return Image.fromarray(depth_gray)
def process_stereogram(input_path: str, output_path: str = None,
period: int = None, strip_width: int = 8,
max_shift: int = 35, smooth_sigma: float = 15.0,
colorize: bool = True, verbose: bool = True):
"""
Full pipeline: load → find period → compute disparity → depth map → save.
"""
inp = Path(input_path)
if output_path is None:
output_path = str(inp.parent / (inp.stem + "_depthmap.png"))
if verbose:
print(f"Loading: {input_path}")
img = Image.open(input_path)
if verbose:
print(f" Size: {img.size}, Mode: {img.mode}")
gray = to_grayscale(img)
# Step 1: find period
if period is None:
if verbose:
print(" Detecting tile period via autocorrelation...")
period = find_period(gray)
if verbose:
print(f" Detected period: {period}px")
else:
if verbose:
print(f" Using provided period: {period}px")
# Step 2: compute disparity
if verbose:
print(f" Computing disparity (strip_width={strip_width}, max_shift=±{max_shift})...")
disparity = compute_disparity_fast(gray, period,
strip_width=strip_width,
max_shift=max_shift)
# Step 3: smooth
if verbose:
print(f" Smoothing (sigma={smooth_sigma})...")
disparity = smooth_disparity(disparity, sigma=smooth_sigma)
# Step 4: convert to depth image
depth_gray = disparity_to_depthmap(disparity)
# Step 5: save outputs
# Grayscale depth map
gray_out = str(Path(output_path).parent / (Path(output_path).stem + "_gray.png"))
Image.fromarray(depth_gray).save(gray_out)
if verbose:
print(f" Saved grayscale depth map: {gray_out}")
# Colorized
if colorize:
color_img = colorize_depthmap(depth_gray)
color_out = str(Path(output_path).parent / (Path(output_path).stem + "_color.png"))
color_img.save(color_out)
if verbose:
print(f" Saved colorized depth map: {color_out}")
# Side-by-side comparison
w, h = img.size
side_by_side = Image.new("RGB", (w * 2, h))
side_by_side.paste(img.convert("RGB"), (0, 0))
if colorize:
side_by_side.paste(color_img.resize((w, h)), (w, 0))
else:
side_by_side.paste(Image.fromarray(depth_gray).convert("RGB").resize((w, h)), (w, 0))
side_by_side.save(output_path)
if verbose:
print(f" Saved side-by-side: {output_path}")
return disparity, depth_gray
if __name__ == "__main__":
parser = argparse.ArgumentParser(
description="Extract a depth map from a stereogram image."
)
parser.add_argument("input", help="Path to stereogram image")
parser.add_argument("-o", "--output", help="Output path for side-by-side image")
parser.add_argument("--period", type=int, default=None,
help="Override auto-detected tile period (pixels)")
parser.add_argument("--strip-width", type=int, default=8,
help="Comparison strip width (default: 8)")
parser.add_argument("--max-shift", type=int, default=35,
help="Max disparity to search ±N pixels (default: 35)")
parser.add_argument("--smooth", type=float, default=15.0,
help="Gaussian smoothing sigma (default: 15)")
parser.add_argument("--no-color", action="store_true",
help="Skip colorized output")
args = parser.parse_args()
process_stereogram(
args.input,
output_path=args.output,
period=args.period,
strip_width=args.strip_width,
max_shift=args.max_shift,
smooth_sigma=args.smooth,
colorize=not args.no_color,
)
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment