2025, Oct 29 13:00

Theta–radius mapping for a 2D binned detector grid: convert Cartesian indices to polar and avoid cos(theta) folding

Learn how to convert a 2D binned detector grid from Cartesian to polar coordinates, plot theta vs radius in matplotlib, and avoid cos(theta) degeneracy

Turning a binned detector matrix into something analytically useful often starts with the right coordinate system. When the distribution looks roughly circular around a known center, plotting by radius and angle makes structure pop out. The tricky part is not the plotting API but the mapping: how to go from a Cartesian grid of bin indices to a polar view that is consistent and unambiguous. Below is a compact walkthrough of the approach, including why plotting cos(theta) on one axis mixes coordinate systems and how to produce a clean angle–radius visualization instead.

Problem setup

We have a 2D grid of counts, indexed as [x, y]. Some bins are zero, others are flagged as unusable. The center of interest is at (8, 8), and the quick Cartesian visualization uses pcolor and a couple of guide circles.

import numpy as np
from matplotlib import pyplot as plt
from matplotlib.patches import Circle
counts_grid = np.asarray(
[[ 0.,  0.,  0.,  0.,  0.,  0.,  2.,  5.,  2.,  3.,  3.,  0.,  0.,  0.,  0.,  0.,  0.,  0.],
 [ 0.,  0.,  0.,  0.,  0.,  4., 10.,  9.,  7., 10.,  6.,  6.,  0.,  0.,  0.,  0.,  0.,  0.],
 [ 0.,  0.,  0.,  0.,  9., 12., 10., 11., 14., 13., 11., 12.,  6.,  0.,  0.,  0.,  0.,  0.],
 [ 0.,  0.,  0.,  0., 12., 16., 17., 14., 13., 14., 13., 12., 12.,  6.,  0.,  0.,  0.,  0.],
 [ 0.,  0., 10., 11.,  0., 14., 18., 16., 14., 18., 16., 14., 13.,  0.,  9.,  0.,  0.,  0.],
 [ 0.,  7., 10., 13., 13., 16., 16., 15., 14., 16., 13., 16., 13., 13., 13.,  7.,  0.,  0.],
 [ 1.,  6., 15., 14., 17., 14., 13., 13., 14., 15.,  1., 13., 13., 12., 12.,  7.,  2.,  0.],
 [ 5., 13., 11., 14., 12., 14., 14., 16., 16., 16., 12.,  1., 12., 14., 12.,  9.,  5.,  0.],
 [ 2., 11., 11., 16., 13., 17., 15., 14.,  0., 14., 14., 13., 13., 16., 10.,  9.,  6.,  1.],
 [ 4., 11., 13., 12., 14., 14., 16., 16., 14., 18., 16.,  1., 14., 12., 12., 11.,  5.,  1.],
 [ 1.,  7., 10., 11., 13., 14.,  1., 19., 15., 19.,  1.,  1., 14., 14., 11., 10.,  1.,  0.],
 [ 0.,  5., 10., 15., 14., 15., 16.,  1., 14.,  1.,  1., 16., 12., 13., 10.,  5.,  0.,  0.],
 [ 0.,  0.,  7., 12., 16., 15., 13., 17., 14., 16., 14., 14., 14., 14.,  7.,  0.,  0.,  0.],
 [ 0.,  0.,  0.,  7.,  0., 14., 14., 15., 16., 16., 14., 11., 13.,  0.,  0.,  0.,  0.,  0.],
 [ 0.,  0.,  0.,  0.,  8., 12., 14., 12., 14., 10., 11., 12.,  7.,  0.,  0.,  0.,  0.,  0.],
 [ 0.,  0.,  0.,  0.,  0.,  8.,  8., 11.,  9.,  9., 10.,  5.,  0.,  0.,  0.,  0.,  0.,  0.],
 [ 0.,  0.,  0.,  0.,  0.,  0.,  4.,  3.,  7.,  6.,  3.,  0.,  0.,  0.,  0.,  0.,  0.,  0.],
 [ 0.,  0.,  0.,  0.,  0.,  0.,  0.,  2.,  0.,  1.,  0.,  0.,  0.,  0.,  0.,  0.,  0.,  0.]]
)/12
counts_grid[4][4] = None
counts_grid[-5][-5] = None
counts_grid[4][-5] = None
counts_grid[-5][4] = None
counts_grid[8][8] = None
# Broken cells
counts_grid[6, 10] = None
counts_grid[10, 6] = None
counts_grid[7, 11] = None
counts_grid[11, 7] = None
counts_grid[11, 9] = None
counts_grid[11, 10] = None
counts_grid[10, 11] = None
counts_grid[9, 11] = None
counts_grid[10, 10] = None
fig_obj, axes_obj = plt.subplots()
plt.pcolor(counts_grid)
plt.colorbar(label="Count per hour")
ring = Circle((8.5, 8.5), 8, edgecolor='red', facecolor='none', linewidth=1)
axes_obj.add_patch(ring)
ring = Circle((8.5, 8.5), 6, edgecolor='red', facecolor='none', linewidth=1)
axes_obj.add_patch(ring)
plt.show()

What’s really going on

The goal was to put cos(phi) on the x-axis and distance from the center on the y-axis. In this context the in-plane angle is more naturally referred to as theta. Using cos(theta) as an axis mixes representations: x = r*cos(theta) is part of the Cartesian-from-polar transform, so you would end up with one axis in polar form and the other not. There is also an ambiguity: cos(theta) maps two different angles to the same value, so points at theta = pi/4 and theta = -pi/4 would collapse on the same x-value. That is fine only if your analysis explicitly wants that degeneracy.

A cleaner diagnostic for circular or radial structure is to work in polar coordinates consistently. Compute theta and radius for each bin center relative to the chosen center, and then plot theta on one axis and radius on the other, coloring points by the bin’s count. That keeps a one-to-one mapping between bins and plotted points, avoids unintended folding, and lets you visually assess radial symmetry.

Solution: convert grid indices to polar and scatter theta vs radius

The following snippet builds Cartesian coordinates for each bin index with meshgrid, shifts them so the origin is at (8, 8), flattens the arrays, removes zero-valued bins, converts to polar, and scatters theta vs radius with color indicating the bin count.

x_idx = np.arange(counts_grid.shape[1])
y_idx = np.arange(counts_grid.shape[0])
xx, yy = np.meshgrid(x_idx, y_idx)
origin = (8, 8)
xx = xx - origin[0]
yy = yy - origin[1]
xx_vec = xx.flatten()
yy_vec = yy.flatten()
vals_vec = counts_grid.flatten()
# Keep nonzero bins
keep = vals_vec != 0
xx_vec = xx_vec[keep]
yy_vec = yy_vec[keep]
vals_vec = vals_vec[keep]
angle = np.atan2(yy_vec, xx_vec)
radius = np.sqrt(xx_vec ** 2 + yy_vec ** 2)
plt.scatter(angle, radius, c=vals_vec, s=30)
plt.xlabel("Theta (radians)")
plt.ylabel("Radius (unknown unit)")
plt.colorbar(label="Count per hour")
plt.show()

If your downstream analysis explicitly needs cos(theta), you can project the angular coordinate at the plotting step by replacing the scatter call with the following line. Be aware that this intentionally folds symmetric angles onto the same x-values.

plt.scatter(np.cos(angle), radius, c=vals_vec, s=30)

Why this matters

Working directly with theta and radius aligns the visualization with the geometry of a radially structured field. It avoids mixing polar and Cartesian components on different axes, keeps each bin uniquely represented, and makes patterns along radius or angle easier to see. Removing zero-valued bins keeps the plot focused on informative cells and reduces visual clutter. If you later decide to project the angular dimension with cos(theta), you will do so consciously, knowing that opposing angles will coincide.

Takeaways

When a 2D grid suggests circular symmetry, convert grid indices to a polar parameterization relative to the center of interest and visualize theta versus radius. Use meshgrid to generate Cartesian coordinates for bins, shift to the chosen origin, derive theta with atan2 and radius with sqrt, and color points by their counts. Save cos(theta) for cases where collapsing symmetric angles is intentional; otherwise keep theta intact for an unambiguous view.

The article is based on a question from StackOverflow by David K. and an answer by Nick ODell.