finding grain boundaries by fourier peak finding

This commit is contained in:
swim committed 2026-11-08 18:16:38 +09:00
1 parent 53d30f714e
commit d0c8d8723a
2 files changed
+1309

No files matched your search

+675
View File
@@ -0,0 +1,675 @@
{
"cells": [
{
"cell_type": "code",
"execution_count": 1,
"id": "0e5a26dc",
"metadata": {
"execution": {
"iopub.execute_input": "2026-09-28T10:01:56.916372Z",
"iopub.status.busy": "2026-09-28T10:01:56.916287Z",
"iopub.status.idle": "2026-09-28T10:01:57.192257Z",
"shell.execute_reply": "2026-09-28T10:01:57.191949Z"
}
},
"outputs": [],
"source": [
"import os\n",
"import numpy as np\n",
"from matplotlib import pyplot as plt, colormaps\n",
"import tifffile"
]
},
{
"cell_type": "code",
"execution_count": 2,
"id": "7b1d6105",
"metadata": {
"execution": {
"iopub.execute_input": "2026-09-28T10:01:57.193312Z",
"iopub.status.busy": "2026-09-28T10:01:57.193168Z",
"iopub.status.idle": "2026-09-28T10:01:57.359007Z",
"shell.execute_reply": "2026-09-28T10:01:57.358776Z"
}
},
"outputs": [
{
"data": {
"text/plain": [
"(30, 755, 755)"
]
},
"execution_count": 2,
"metadata": {},
"output_type": "execute_result"
}
],
"source": [
"obj = tifffile.imread(\"./obj_phase_roi_Niter120.tiff\")\n",
"obj.shape"
]
},
{
"cell_type": "code",
"execution_count": 25,
"id": "298c10f5",
"metadata": {},
"outputs": [
{
"name": "stdout",
"output_type": "stream",
"text": [
"Pixel size: 0.24413245033112582 Angstrom\n"
]
}
],
"source": [
"ROI_LENGTH_A = 0.36 * 512\n",
"PIXEL_SIZE_A = ROI_LENGTH_A / obj.shape[-1]\n",
"print(f\"Pixel size: {PIXEL_SIZE_A} Angstrom\")\n",
"LAYER_DISTANCE_A = 10"
]
},
{
"cell_type": "markdown",
"id": "6131af01",
"metadata": {},
"source": [
"## Task 1 — estimate and remove the global column tilt\n",
"\n",
"Successive $z$ slices are registered with sub-pixel phase correlation after removing the slowly varying background and tapering the image edges. The cumulative registration shifts are fitted by a straight line; its slope is the global correction vector per slice. The physical column drift is the negative of that vector.\n",
"\n",
"The TIFF does not contain a physical $z$ spacing, so the result below is reported in lateral pixels per slice. The in-plane azimuth uses image coordinates: $+x$ is right, $+y$ is down, and angles increase clockwise."
]
},
{
"cell_type": "code",
"execution_count": 27,
"id": "633a13a8",
"metadata": {
"execution": {
"iopub.execute_input": "2026-09-28T10:01:57.359991Z",
"iopub.status.busy": "2026-09-28T10:01:57.359908Z",
"iopub.status.idle": "2026-09-28T10:02:00.049288Z",
"shell.execute_reply": "2026-09-28T10:02:00.048880Z"
}
},
"outputs": [
{
"name": "stdout",
"output_type": "stream",
"text": [
"Alignment correction (dy, dx): [0.19235818 0.05482759] pixels/slice = [4.69608743 1.33851936] mrad\n",
"Column drift (dy, dx): [-0.19235818 -0.05482759] pixels/slice = [-4.69608743 -1.33851936] mrad\n",
"Drift magnitude: 0.2000 pixels/slice\n",
"Directed azimuth: 254.09° from +x\n",
"Undirected tilt axis: 74.09° from +x\n",
"Drift over the full stack: [-5.57838727 -1.59000007] pixels = [-1.36186535 -0.38817061] Angstrom\n",
"Overlap crop (y, x): (slice(3, 752, None), slice(1, 754, None))\n",
"fixed_image.shape: (749, 753)\n",
"Saved: fixed_cropped_projection.tiff\n"
]
}
],
"source": [
"from scipy.ndimage import gaussian_filter, shift as ndi_shift\n",
"from scipy.signal.windows import tukey\n",
"from skimage.registration import phase_cross_correlation\n",
"\n",
"\n",
"def estimate_global_tilt(volume, upsample_factor=20, highpass_sigma=6.0):\n",
" \"\"\"Return the global alignment shift (dy, dx) per z slice.\"\"\"\n",
" stack = np.asarray(volume, dtype=np.float32)\n",
" if stack.ndim != 3 or stack.shape[0] < 2:\n",
" raise ValueError(\"volume must have shape (z, y, x) with at least two slices\")\n",
"\n",
" # Keep atom-scale contrast and suppress edge discontinuities before FFT\n",
" # registration. Registering neighbours avoids lattice-period ambiguity.\n",
" highpass = stack - gaussian_filter(\n",
" stack, sigma=(0, highpass_sigma, highpass_sigma)\n",
" )\n",
" edge_window = np.outer(\n",
" tukey(stack.shape[1], alpha=0.2),\n",
" tukey(stack.shape[2], alpha=0.2),\n",
" ).astype(np.float32)\n",
" registration_stack = highpass * edge_window[None, :, :]\n",
"\n",
" step_shifts = np.array(\n",
" [\n",
" phase_cross_correlation(\n",
" registration_stack[z - 1],\n",
" registration_stack[z],\n",
" upsample_factor=upsample_factor,\n",
" normalization=None,\n",
" )[0]\n",
" for z in range(1, stack.shape[0])\n",
" ]\n",
" )\n",
"\n",
" cumulative_shifts = np.vstack(\n",
" (np.zeros(2), np.cumsum(step_shifts, axis=0))\n",
" )\n",
" z_coordinates = np.arange(stack.shape[0], dtype=float)\n",
" alignment_per_slice = np.array(\n",
" [\n",
" np.polyfit(z_coordinates, cumulative_shifts[:, component], 1)[0]\n",
" for component in range(2)\n",
" ]\n",
" )\n",
" fitted_shifts = (\n",
" z_coordinates - z_coordinates.mean()\n",
" )[:, None] * alignment_per_slice\n",
" return alignment_per_slice, fitted_shifts, step_shifts, cumulative_shifts\n",
"\n",
"\n",
"def align_and_crop(volume, fitted_shifts):\n",
" \"\"\"Apply (dy, dx) shifts and crop to the overlap shared by all slices.\"\"\"\n",
" stack = np.asarray(volume, dtype=np.float32)\n",
" aligned = np.stack(\n",
" [\n",
" ndi_shift(\n",
" stack[z],\n",
" shift=tuple(fitted_shifts[z]),\n",
" order=3,\n",
" mode=\"constant\",\n",
" cval=0.0,\n",
" prefilter=True,\n",
" )\n",
" for z in range(stack.shape[0])\n",
" ]\n",
" )\n",
"\n",
" dy, dx = fitted_shifts.T\n",
" y0 = max(0, int(np.ceil(dy.max())))\n",
" y1 = min(stack.shape[1], stack.shape[1] + int(np.floor(dy.min())))\n",
" x0 = max(0, int(np.ceil(dx.max())))\n",
" x1 = min(stack.shape[2], stack.shape[2] + int(np.floor(dx.min())))\n",
" if y1 <= y0 or x1 <= x0:\n",
" raise ValueError(\"estimated shifts leave no common image area\")\n",
" return aligned[:, y0:y1, x0:x1], (slice(y0, y1), slice(x0, x1))\n",
"\n",
"\n",
"alignment_dy_dx, fitted_shifts, step_shifts, cumulative_shifts = \\\n",
" estimate_global_tilt(obj)\n",
"fixed_stack, overlap_crop = align_and_crop(obj, fitted_shifts)\n",
"fixed_image = fixed_stack.sum(axis=0)\n",
"\n",
"# A shift aligns the current slice to the preceding slice, so the actual\n",
"# movement of a column as z increases has the opposite sign.\n",
"tilt_dy_dx = -alignment_dy_dx\n",
"tilt_magnitude = np.hypot(*tilt_dy_dx)\n",
"tilt_azimuth = np.degrees(np.arctan2(tilt_dy_dx[0], tilt_dy_dx[1])) % 360\n",
"tilt_axis = tilt_azimuth % 180\n",
"total_tilt_dy_dx = tilt_dy_dx * (obj.shape[0] - 1)\n",
"\n",
"print(f\"Alignment correction (dy, dx): {alignment_dy_dx} pixels/slice = {alignment_dy_dx * PIXEL_SIZE_A / LAYER_DISTANCE_A * 1000} mrad\")\n",
"print(f\"Column drift (dy, dx): {tilt_dy_dx} pixels/slice = {tilt_dy_dx * PIXEL_SIZE_A / LAYER_DISTANCE_A * 1000} mrad\")\n",
"print(f\"Drift magnitude: {tilt_magnitude:.4f} pixels/slice\")\n",
"print(f\"Directed azimuth: {tilt_azimuth:.2f}° from +x\")\n",
"print(f\"Undirected tilt axis: {tilt_axis:.2f}° from +x\")\n",
"print(f\"Drift over the full stack: {total_tilt_dy_dx} pixels = {total_tilt_dy_dx * PIXEL_SIZE_A} Angstrom\")\n",
"print(f\"Overlap crop (y, x): {overlap_crop}\")\n",
"print(f\"fixed_image.shape: {fixed_image.shape}\")\n",
"\n",
"# Return a reusable image variable and an on-disk float32 TIFF.\n",
"fixed_image_path = \"fixed_cropped_projection.tiff\"\n",
"tifffile.imwrite(fixed_image_path, fixed_stack.astype(np.float32))\n",
"print(f\"Saved: {fixed_image_path}\")"
]
},
{
"cell_type": "code",
"execution_count": 4,
"id": "1e9c2b93",
"metadata": {
"execution": {
"iopub.execute_input": "2026-09-28T10:02:00.050342Z",
"iopub.status.busy": "2026-09-28T10:02:00.050194Z",
"iopub.status.idle": "2026-09-28T10:02:00.339494Z",
"shell.execute_reply": "2026-09-28T10:02:00.339168Z"
}
},
"outputs": [
{
"data": {
"image/png": "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vFOx2WxKQ0ODEggExL0KCgqUrKwspbi4WLn33nuVgwcPKseOHVMmJyeVvLw8paCgQPn1r3+t7N+/X/nOd76jmEwm5aabbnrd57311lsVAMpHPvIR5fHHH1f+/Oc/Kz/96U+VW2+9VZzz/e9/XwGgXHfddcozzzyjPPDAA0pxcbHicDiUnp4ecd6NN96oGAwGpbCwUPnBD36gHDhwQNm7d6+iKIoCQMnJyVHq6uqUhx56SHn++eeVtrY2pb29XXE4HEptba3ywAMPKH/+85+Vm2++WdFqtcptt90mru3xeJSamhrFZrMpd9xxh7J3717liSeeUL7yla8ozz//vBIIBJQ9e/YoAJTPfOYzop/7+vrixpU6wRt9LqPRqFRVVSl33nmnsn//fuVb3/qWotFoztJnVKhQoeLvBT6fT3E4HMr69esVRVGUu+++WwGg/Pa3vxXnNDU1KZ/5zGcUAMqePXuUI0eOKENDQ6+5nl7oun3w4EGx9n/sYx9Tnn76aWX37t2Ky+U6Z3vD4bCyY8cORa/XK//2b/+mPPvss8rTTz+t3HLLLcrDDz+sKIrylsjN12rX7373O0Wj0Sgf+tCHlF27dil/+tOflKuuukrR6XTK/v37xbVPnTql2O12pbCwULnrrruUAwcOKL///e+Vj3/844rH41FmZmaEjPnFL35xli154403KgUFBeJ6b/S5cnNzlerqauWBBx5Q9u7dq1xzzTUKAOXw4cOvOy+Ki4uVj3/843HHzjUPaMuutNNjsZhyxRVXKMnJycrQ0JCiKIpy7733KgCUu+++W5x3oX25f/9+RafTKe95z3uUXbt2KY8//riyfv16JT8//yy+4vVwLo6D/bVq1Srl4YcfVp599lll48aNisFgUL71rW8pW7ZsUXbt2qU8+eSTSnl5uZKRkSH0N0VRlMcff1z51re+pTz55JPK4cOHlUceeUTZvn27kpaWFseRNDc3K2azWamrq1MeeeQR5emnn1auuOIKpbCw8Cz943vf+56i0WiUT3/608ru3buVXbt2KZs3b1ZsNpvS3t4e1/5IJKLY7XblX//1X99QX6j420El0FWoeIdgampKAaBce+21Z30XiUSUcDgsPrFYTHy3Urh0dnYqAJQvfOELcdd49dVXFQDKLbfcIo5t375dAaD88Y9/jDv3n/7pnxStVqsMDw+fs63RaFQJh8PKHXfcoaSkpMS150IIdEVRlJqamnOedyEEuqIoypVXXhmnrLwebrzxRsVms511XCbQFUVRjh8/ftb9VahQoULFOw+UHSs/JpNJ+eUvfxl37sMPP6wAUJ544om445QJ8vnnkickKH784x/HHX/00UcVAMpvfvMbcaygoEDR6XRKd3d33LmLine truncated
"text/plain": [
"<Figure size 1500x500 with 3 Axes>"
]
},
"metadata": {},
"output_type": "display_data"
}
],
"source": [
"# Registration diagnostic and before/after comparison.\n",
"z_coordinates = np.arange(obj.shape[0])\n",
"fitted_path = (\n",
" z_coordinates[:, None] * alignment_dy_dx\n",
" + (cumulative_shifts - z_coordinates[:, None] * alignment_dy_dx).mean(axis=0)\n",
")\n",
"\n",
"fig, axes = plt.subplots(1, 3, figsize=(15, 5), constrained_layout=True)\n",
"axes[0].plot(z_coordinates, cumulative_shifts[:, 1], \"o\", ms=3, label=\"measured dx\")\n",
"axes[0].plot(z_coordinates, cumulative_shifts[:, 0], \"o\", ms=3, label=\"measured dy\")\n",
"axes[0].plot(z_coordinates, fitted_path[:, 1], \"-\", lw=1.5, label=\"linear dx fit\")\n",
"axes[0].plot(z_coordinates, fitted_path[:, 0], \"-\", lw=1.5, label=\"linear dy fit\")\n",
"axes[0].set(xlabel=\"z slice\", ylabel=\"alignment shift (pixels)\", title=\"Global tilt fit\")\n",
"axes[0].legend(fontsize=8)\n",
"axes[0].grid(alpha=0.2)\n",
"\n",
"original_cropped = obj[(slice(None),) + overlap_crop].sum(axis=0)\n",
"display_limits = np.percentile(\n",
" np.concatenate((original_cropped.ravel(), fixed_image.ravel())), (1, 99.8)\n",
")\n",
"for ax, image, title in zip(\n",
" axes[1:],\n",
" (original_cropped, fixed_image),\n",
" (\"Before correction\", \"After correction (fixed_image)\"),\n",
"):\n",
" ax.imshow(image, cmap=\"gray\", vmin=display_limits[0], vmax=display_limits[1])\n",
" ax.set_title(title)\n",
" ax.set_axis_off()\n",
"plt.show()"
]
},
{
"cell_type": "markdown",
"id": "1463886a",
"metadata": {},
"source": [
"## Per-layer reciprocal-lattice domain coloring\n",
"\n",
"This ports the calibrated Si $[110]$ reciprocal-grid method from `grain-boundaries.ipynb` to the tilt-corrected stack. The fit uses the summed stack for a high-SNR reference, greedily removes reflections belonging to each fitted orientation, and stops when the residual grid has fewer than 12 matched reflections or less than 15% of the dominant score.\n",
"\n",
"The source notebook's calibration, $92\\,\\mathrm{Å}/379$ pixels, is retained. Each accepted orientation gets a rotated ideal-grid mask. Reciprocal-space pixels shared by multiple masks are removed because they do not distinguish the grains; real-space atoms supported by more than one orientation still receive an additive mixture of the corresponding colors."
]
},
{
"cell_type": "code",
"execution_count": 34,
"id": "b825da5d",
"metadata": {
"execution": {
"iopub.execute_input": "2026-09-28T10:02:00.341856Z",
"iopub.status.busy": "2026-09-28T10:02:00.341761Z",
"iopub.status.idle": "2026-09-28T10:02:12.009745Z",
"shell.execute_reply": "2026-09-28T10:02:12.009493Z"
}
},
"outputs": [
{
"name": "stdout",
"output_type": "stream",
"text": [
"Fitted reciprocal scale correction: 0.9950\n",
"Orientation 1: 72.50°, score= 407.07 (1.000 of dominant), matched reflections=36\n",
"Orientation 2: 2.00°, score= 165.96 (0.408 of dominant), matched reflections=20\n",
"Orientation 3: 143.50°, score= 136.48 (0.335 of dominant), matched reflections=14\n"
]
},
{
"data": {
"image/png": "iVBORw0KGgoAAAANSUhEUgAABN4AAAJjCAYAAADTddy3AAAAOnRFWHRTb2Z0d2FyZQBNYXRwbG90bGliIHZlcnNpb24zLjEwLjYsIGh0dHBzOi8vbWF0cGxvdGxpYi5vcmcvq6yFwwAAAAlwSFlzAAAPYQAAD2EBqD+naQABAABJREFUeJzsvXecJVWZ8P89lW++nfN0z/REJjLADEMOS4aXIAprIqqo+C7qqqhIUBRF3QVxWfWngu+CLCsmMKEgIEoamGGGSUzs6enu6Zxuqnx+f/R0S0+ABgm61PfzuZ+ZPnXq1FOnzr311FNPEFJKSURERERERERERERERERERERERMTrivJWCxAREREREREREREREREREREREfG/kcjwFhERERERERERERERERERERER8QYQGd4iIiIiIiIiIiIiIiIiIiIiIiLeACLDW0RERERERERERERERERERERExBtAZHiLiIiIiIiIiIiIiIiIiIiIiIh4A4gMbxEREREREREREREREREREREREW8AkeEtIiIiIiIiIiIiIiIiIiIiIiLiDSAyvEVERERERERERERERERERERERLwBRIa3iIiIiIiIiIiIiIiIiIiIiIiIN4DI8PZ3yp133okQgmefffYV+1588cW0tLS8ofJcf/31CCGm1PfBBx/k5JNPpr6+HtM0qa+v57jjjuOrX/3qpH4tLS1cfPHFLztWW1sbQoiJz3333TexLZfL8elPf5qTTz6ZqqoqhBBcf/31+x3nz3/+M5dffjmHHHIIpmkihKCtre2Ax73tttuYO3cupmkyffp0brjhBjzPm9TnlltumSRbf3//y57L68Gjjz6KEIJHH330Ffu+nuuira2NM844g/LycoQQXHXVVRPX5s4775zo98QTT3D99dczPDy8zxi33377pL6vJy937cfp6uri+uuv5/nnn39DZIgY49Ws0anwRq6bl9LS0sKZZ575mvY97rjjJv0WvPSzbt064K/zsr/P+eefz8UXX3zA7S/9vNJvZkRERMRbyVNPPcU73/lO6urqMAyD2tpazj//fJ588slXNc6r0TtfK8Vikeuvv/5vul+9nG7xZpzD3xOv9/3/zR7/reZ/2/lN5Tnv743jjjuOBQsWvNViRPwvRXurBYj438V3vvMdPvzhD/OOd7yDb3/725SXl7Nr1y6eeOIJ7rvvPq6++uqJvj//+c9Jp9NTGveaa67hjDPOYPbs2RNtAwMDfO9732Px4sWcc845fP/73z/g/g8//DAPPfQQBx98MOl0+mVval/+8pf5whe+wNVXX83JJ5/MypUrueaaa+js7OR73/veRL8LL7yQww8/nO9///v84Ac/mNJ5/K0sXbqUJ598koMOOuhNOd44H//4x3n66af54Q9/SG1tLXV1ddTW1vLkk0/S2to60e+JJ57ghhtu4OKLLyabzU4a4/bbb6eysvItuwl3dXVxww030NLSwpIlS94SGSJePW/1upkqM2bM4O67796n/aXfD4CvfOUrHH/88ZPaKioqUBSFK664YqJt1apVfPSjH92nf1VV1esseURERMTrw2233cZVV13FsmXLuPnmm2lubqa9vZ3/+I//4KijjuLWW2/lyiuvnNJYl19+OaeeeuobKm+xWOSGG24Axh64Xwsvp1u8GefwduKt0oHfLP63n19ExNudyPAW8bpy0003ccwxx0zyTAN43/veRxiGk9oOPvjgKY/b2trK4YcfPqmtubmZoaGhCW+zlzO8feELX+C6664D4Bvf+MYBDW8DAwPceOONfOADH+ArX/kKMKaMeZ7HNddcw1VXXTVxQ6ytraW2tpbf/e53Uz6P14rneQghSKfT+8zDm8G6detYtmwZ55xzzqT2t0KWiIi/R2Kx2JS+D7NmzTpgv5ca6WzbfsX+EREREX8v/OUvf+Gqq67i9NNP5+c//zma9tdHjAsvvJBzzz2Xf/mXf+Hggw/myCOPPOA4xWKReDxOY2MjjY2Nb4bobxj/G87h74m3SgfeH+N6+UvX+d/K631+Ukps2yYWi71uY0ZERLx2olDTfzDuvPNO5syZg2mazJs3j//3//7ffvu5rsuNN944ES5ZVVXFJZdcQl9f36R+9957LyeffDJ1dXXEYjHmzZvH1VdfTaFQeE3yDQwMUFdXt99tijJ5uf2tLsjjoVdTYe9jH4jf/e532LbNJZdcMqn9kksuQUrJL37xi1cr5gRSSr7yla/Q3NyMZVkceuih/OEPf+C4446b9KZ13NX8v/7rv/jkJz9JQ0MDpmmydevWA7qhT3Vd/Od//ieLFy8mmUySSqWYO3cun/vc5w4o8/jxtm7dym9/+9uJOW9ra9sn1PT666/nU5/6FADTp0+f6Pvoo4/S0tLC+vXreeyxxybaXxoGOzo6yr/+678yffp0DMOgoaGBq666ap91ODo6ygc+8AEqKipIJpOceuqpbN68+RXn/tFHH+Wwww4Dxq7luAwvDU+9//77WbFiBfF4nFQqxUknnTSl0JgwDLnxxhuZM2cOsViMbDbLokWLuPXWWyf6HCjsd39hKEIIrrzySu64446JMQ899FCeeuoppJR8/etfZ/r06SSTSU444QS2bt06af9xN/knn3ySI444glgsRktLC3fccQcAv/71r1m6dCnxeJyFCxfu13C8ZcsW3v3ud1NdXT2xpv7jP/5jn36bNm3i1FNPJR6PU1lZyRVXXEEul3vFORtn+/btXHjhhRNh6TU1NZx44okTITsvt25s2+aTn/wkS5YsIZPJUF5ezooVK/jlL3+5z3HCMOS2225jyZIlE9fo8MMP5/77739Z+W6//XY0TZsw2kdERERE7J+bbroJIQT/+Z//uY8xQtM0br/9doQQk9KOjN8DV61axfnnn09ZWdnEC4gDhWnee++9rFixgkQiQTKZ5JRTTmH16tWT+lx88cUkk0m2bt3K6aefTjKZpKmpiU9+8pM4jgOMpdAY9yC+4YYb9gnn37p1K5dccgmzZs0iHo/T0NDAWWedxQsvvDBxnFfSLfZ3DmEYcvPNN0/o59XV1bz//e+no6NjUr/xe/nKlSs5+uijicfjzJgxg69+9av7vMg+EFN5FvjqV7+Koig88MAD+8xhPB6fdL6bNm3in//5n6mpqcE0TaZNm8b73//+iTndH3vruC8df2+96JV01L114PGUL3vrQQCf+cxnMAxjUhqYhx56iBNPPJF0Ok08HufII4/k4YcfPqDsex93f3r5qxn3leZvfzr++Fpev349J554IolEgqqqKq688kqKxeKk8cf1x+985zvMmzcP0zT50Y9+BIyl3DnxxBNJpVLE43GOOOIIfv3rX+8jY2dnJx/84AdpamrCMAzq6+s5//zz6enpAV6d7jVV/la99w9/+ANnn302jY2NWJbFzJkz+dCHPrRPCqC+vr6Jcxv/Phx55JE89NBDLyvfz3/+c+LxOJdffjm+7wPwk5/8hOXLl5PJZCa+m5deeulrnoOItweRx9s/EHfeeSeXXHIJZ599Nt/85jcZGRnh+uuvx3GcSYalMAw5++yzefzxx/n0pz/NEUccwc6dO7nuuus47rjjePbZZyfefmzZsoXTTz+dq666ikQiwaZNm/ja177GM888wx//+MdXLeOKFSv46U9/yvXXX8+5557LggULUFX1dZuDN5rxfEwLFy6c1F5XV0dlZeXE9tfC5z//eW666SY++MEPct5557Fr1y4uv/xyPM+bFEI7zmc/+1lWrFjBd77zHRRFobq6mu7u7n36TXVd/Pd//zcf+chH+NjHPsY3vvENFEVh69atbNiw4YAyj7u9n3vuubS2tvKNb3xjYj527949qe/ll1/O4OAgt912Gz/72c8mDLAHHXQQP//5zzn//PPJZDLcfvvtAJimCYy93T722GPp6Ojgc5/7HIsWLWL9+vVce+21vPDCCzz00EMIIZBScs455/DEE09w7bXXcthhh/GXv/yF00477RXnfunSpdxxxx1ccsklE2HLwMSb6B//+Me85z3v4eSTT+aee+7BcRxuvvlmjjvuOB5++GGOOuqoA4598803c/3113PNNddwzDHH4HkemzZt2m+eu6nyq1/9itWrV/PVr34VIQSf+cxnOOOMM7jooovYvn073/72txkZGeETn/gE73jHO3j++ecnKffd3d1ccsklfPrTn6axsZHbbruNSy+9lF27dnHffffxuc99jkwmwxe/+EXOOecctm/fTn19PQAbNmzgiCOOYNq0aXzzm9+ktraWBx98kP/7f/8v/f39E0aonp4ejj32WHRd5/bbb6empoa77757ymFEAKeffjpBEHDzzTczbdo0+vv7eeKJJybm7uXWjeM4DA4O8q//+q80NDTgui4PPfQQ5513HnfccQfvf//7J45z8cUXc9ddd3HZZZfxxS9+EcMwWLVq1QHzPEop+dSnPsW3vvUtvv/970/5BcG4QjaOoij7GP3DMNyn3+v5xjwiIiLizSYIAh555BEOPfTQA3p4NTU1ccghh/DHP/6RIAgm6YbnnXceF154IVdcccXLvvj9yle+wjXXXDNxL3ddl69//escffTRPPPMM5NC9DzP4//8n//DZZddxic/+Un+9Kc/8aUvfYlMJsO1115LXV0dv/vd7zj11FO57LLLuPzyy4G/hvN3dXVRUVHBV7/6VaqqqhgcHORHP/oRy5cvZ/Xq1cyZM+cVdYv98eEPf5jvfe97XHnllZx55pm0tbXxhS98gUcffZRVq1ZRWVk50be7u5v3vOc9fPKTn+S6667j5z//OZ/97Gepr6+fdI/bH1N9FvjMZz7D448/zkUXXcTq1atpbm7mjjvu4Ec/+hHf//73J/ThNWvWcNRRR1FZWckXv/hFZs2axe7du7n//vtxXXfi3vxaeS066nvf+14+85nPcOedd3LjjTdOtAdBwF133cVZZ501MZ933XUX73//+zn77LPLine truncated
"text/plain": [
"<Figure size 1300x600 with 2 Axes>"
]
},
"metadata": {},
"output_type": "display_data"
}
],
"source": [
"from scipy.ndimage import maximum_filter\n",
"\n",
"\n",
"# Calibration and fit controls inherited from grain-boundaries.ipynb.\n",
"pixel_size_A = PIXEL_SIZE_A\n",
"d_004_A = 1.358\n",
"a_si_A = 4 * d_004_A\n",
"match_tolerance_px = 6\n",
"mask_radius_px = 8\n",
"minimum_matched_reflections = 12\n",
"minimum_relative_score = 0.15\n",
"\n",
"coloring_volume = np.asarray(fixed_stack, dtype=np.float32)\n",
"fit_image = coloring_volume[-1]\n",
"height, width = fit_image.shape\n",
"centre_y, centre_x = (np.asarray(fit_image.shape) - 1) / 2\n",
"yy, xx = np.ogrid[:height, :width]\n",
"\n",
"fit_fft = np.fft.fftshift(np.fft.fft2(fit_image))\n",
"fit_spectrum = np.log1p(np.abs(fit_fft))\n",
"radius_px = np.hypot(xx - centre_x, yy - centre_y)\n",
"\n",
"# Detect the strongest non-central local FFT maxima.\n",
"local_maximum = maximum_filter(fit_spectrum, size=7, mode=\"wrap\")\n",
"candidate_pixels = np.argwhere((fit_spectrum == local_maximum) & (radius_px > 8))\n",
"candidate_strength = fit_spectrum[candidate_pixels[:, 0], candidate_pixels[:, 1]]\n",
"strongest = np.argsort(candidate_strength)[-min(600, len(candidate_pixels)):]\n",
"candidate_pixels = candidate_pixels[strongest]\n",
"candidate_strength = candidate_strength[strongest]\n",
"\n",
"# Allowed diamond-cubic Si [110] reciprocal points. Coordinates are physical\n",
"# reciprocal units (1/Å) along [1 -1 0] and [001].\n",
"grid_hkls, grid_reciprocal_xy = [], []\n",
"for h in range(-12, 13):\n",
" for l in range(-12, 13):\n",
" if h == 0 and l == 0:\n",
" continue\n",
" all_odd = (h % 2 != 0) and (l % 2 != 0)\n",
" diamond_even = (h % 2 == 0) and (l % 2 == 0) and (l % 4 == 0)\n",
" if all_odd or diamond_even:\n",
" grid_hkls.append((h, -h, l))\n",
" grid_reciprocal_xy.append((np.sqrt(2) * h / a_si_A, l / a_si_A))\n",
"grid_hkls = np.asarray(grid_hkls)\n",
"grid_reciprocal_xy = np.asarray(grid_reciprocal_xy)\n",
"\n",
"\n",
"def projected_si110_grid(angle_deg, scale_factor):\n",
" \"\"\"Return in-frame (hkl, [y, x]) sites for a rotated ideal grid.\"\"\"\n",
" angle = np.deg2rad(angle_deg)\n",
" rotation = np.array(\n",
" [[np.cos(angle), -np.sin(angle)],\n",
" [np.sin(angle), np.cos(angle)]]\n",
" )\n",
" reciprocal_xy = scale_factor * grid_reciprocal_xy @ rotation.T\n",
" pixels = np.column_stack(\n",
" (\n",
" centre_y + reciprocal_xy[:, 1] * height * pixel_size_A,\n",
" centre_x + reciprocal_xy[:, 0] * width * pixel_size_A,\n",
" )\n",
" )\n",
" valid = (\n",
" (pixels[:, 0] >= 7) & (pixels[:, 0] < height - 7)\n",
" & (pixels[:, 1] >= 7) & (pixels[:, 1] < width - 7)\n",
" )\n",
" return grid_hkls[valid], pixels[valid]\n",
"\n",
"\n",
"def reciprocal_grid_score(angle_deg, scale_factor, pixels, strengths):\n",
" \"\"\"Score observed maxima near the specified ideal reciprocal grid.\"\"\"\n",
" _, targets = projected_si110_grid(angle_deg, scale_factor)\n",
" distance2 = ((targets[:, None, :] - pixels[None, :, :]) ** 2).sum(axis=2)\n",
" nearest = np.argmin(distance2, axis=1)\n",
" nearest_distance2 = distance2[np.arange(len(targets)), nearest]\n",
" matches = nearest_distance2 <= match_tolerance_px ** 2\n",
" score = np.sum(\n",
" strengths[nearest[matches]]\n",
" * (1 - nearest_distance2[matches] / match_tolerance_px ** 2)\n",
" )\n",
" return float(score), int(matches.sum())\n",
"\n",
"\n",
"# Fit the dominant detector rotation and a small scale correction.\n",
"dominant_score, dominant_angle, best_scale, dominant_match_count = -np.inf, None, None, 0\n",
"for scale in np.linspace(0.90, 1.10, 11):\n",
" for angle in np.arange(0, 180, 1):\n",
" score, match_count = reciprocal_grid_score(\n",
" angle, scale, candidate_pixels, candidate_strength\n",
" )\n",
" if score > dominant_score:\n",
" dominant_score, dominant_angle, best_scale, dominant_match_count = (\n",
" score, angle, scale, match_count\n",
" )\n",
"for scale in np.linspace(best_scale - 0.015, best_scale + 0.015, 13):\n",
" for angle in np.linspace(dominant_angle - 1, dominant_angle + 1, 41):\n",
" score, match_count = reciprocal_grid_score(\n",
" angle, scale, candidate_pixels, candidate_strength\n",
" )\n",
" if score > dominant_score:\n",
" dominant_score, dominant_angle, best_scale, dominant_match_count = (\n",
" score, angle, scale, match_count\n",
" )\n",
"\n",
"# Repeatedly rotate the calibrated grid against residual FFT maxima.\n",
"remaining_pixels = candidate_pixels.copy()\n",
"remaining_strength = candidate_strength.copy()\n",
"orientation_fits = []\n",
"for orientation_index in range(8):\n",
" best_residual = (-np.inf, None, 0)\n",
" for angle in np.arange(0, 180, 0.25):\n",
" score, match_count = reciprocal_grid_score(\n",
" angle, best_scale, remaining_pixels, remaining_strength\n",
" )\n",
" if score > best_residual[0]:\n",
" best_residual = (score, angle, match_count)\n",
"\n",
" score, angle, match_count = best_residual\n",
" if (\n",
" match_count < minimum_matched_reflections\n",
" or score < minimum_relative_score * dominant_score\n",
" ):\n",
" break\n",
" hkls, grid_pixels = projected_si110_grid(angle, best_scale)\n",
" orientation_fits.append(\n",
" {\n",
" \"angle_deg\": float(angle),\n",
" \"score\": float(score),\n",
" \"relative_score\": float(score / dominant_score),\n",
" \"matched_reflections\": int(match_count),\n",
" \"hkls\": hkls,\n",
" \"grid_pixels\": grid_pixels,\n",
" }\n",
" )\n",
"\n",
" # Exclude all residual candidates close to this orientation's full grid.\n",
" distance2 = (\n",
" (remaining_pixels[:, None, :] - grid_pixels[None, :, :]) ** 2\n",
" ).sum(axis=2)\n",
" keep = distance2.min(axis=1) > (2 * match_tolerance_px) ** 2\n",
" remaining_pixels = remaining_pixels[keep]\n",
" remaining_strength = remaining_strength[keep]\n",
"\n",
"if len(orientation_fits) < 2:\n",
" raise RuntimeError(\"Fewer than two statistically supported lattice orientations were found.\")\n",
"\n",
"\n",
"def reciprocal_disk_mask(points_yx, radius):\n",
" \"\"\"Draw circular masks locally, avoiding a full-frame temporary per spot.\"\"\"\n",
" mask = np.zeros((height, width), dtype=bool)\n",
" integer_radius = int(np.ceil(radius))\n",
" for y, x in points_yx:\n",
" y0 = max(0, int(np.floor(y)) - integer_radius)\n",
" y1 = min(height, int(np.floor(y)) + integer_radius + 2)\n",
" x0 = max(0, int(np.floor(x)) - integer_radius)\n",
" x1 = min(width, int(np.floor(x)) + integer_radius + 2)\n",
" patch_y, patch_x = np.ogrid[y0:y1, x0:x1]\n",
" mask[y0:y1, x0:x1] |= (\n",
" (patch_y - y) ** 2 + (patch_x - x) ** 2 <= radius ** 2\n",
" )\n",
" return mask\n",
"\n",
"\n",
"orientation_masks = np.stack(\n",
" [\n",
" reciprocal_disk_mask(fit[\"grid_pixels\"], mask_radius_px)\n",
" for fit in orientation_fits\n",
" ]\n",
")\n",
"\n",
"# Reflections common to multiple grids carry no orientation information.\n",
"shared_reciprocal_pixels = orientation_masks.sum(axis=0) > 1\n",
"orientation_masks &= ~shared_reciprocal_pixels[None, :, :]\n",
"\n",
"grain_colors = np.array(\n",
" [\n",
" [0.10, 0.45, 1.00], # dominant: blue\n",
" [1.00, 0.12, 0.05], # second: red\n",
" [0.15, 1.00, 0.20], # third: green\n",
" [1.00, 0.25, 0.90], # extra orientations, if statistically supported\n",
" [1.00, 0.75, 0.05],\n",
" [0.10, 1.00, 0.95],\n",
" [0.75, 0.35, 1.00],\n",
" [0.65, 1.00, 0.10],\n",
" ],\n",
" dtype=np.float32,\n",
")[:len(orientation_fits)]\n",
"\n",
"print(f\"Fitted reciprocal scale correction: {best_scale:.4f}\")\n",
"for index, fit in enumerate(orientation_fits, start=1):\n",
" print(\n",
" f\"Orientation {index}: {fit['angle_deg']:6.2f}°, \"\n",
" f\"score={fit['score']:7.2f} \"\n",
" f\"({fit['relative_score']:.3f} of dominant), \"\n",
" f\"matched reflections={fit['matched_reflections']}\"\n",
" )\n",
"\n",
"# Show the measured spectrum, fitted grids, and orientation-exclusive masks.\n",
"fig, axes = plt.subplots(1, 2, figsize=(13, 6), constrained_layout=True)\n",
"axes[0].imshow(\n",
" fit_spectrum,\n",
" cmap=\"magma\",\n",
" vmin=np.percentile(fit_spectrum, 5),\n",
" vmax=np.percentile(fit_spectrum, 99.9),\n",
")\n",
"axes[0].plot(candidate_pixels[:, 1], candidate_pixels[:, 0], \"w.\", ms=1, alpha=0.2)\n",
"for fit, color in zip(orientation_fits, grain_colors):\n",
" grid_pixels = fit[\"grid_pixels\"]\n",
" axes[0].plot(\n",
" grid_pixels[:, 1], grid_pixels[:, 0], \"+\", color=color, ms=4, mew=0.8,\n",
" label=f\"{fit['angle_deg']:.2f}°\",\n",
" )\n",
"axes[0].set_title(\"Ideal Si [110] grids fitted to summed-stack FFT\")\n",
"axes[0].legend(loc=\"upper right\", fontsize=8)\n",
"\n",
"mask_rgb = np.zeros((height, width, 3), dtype=np.float32)\n",
"for mask, color in zip(orientation_masks, grain_colors):\n",
" mask_rgb += mask[..., None] * color\n",
"axes[1].imshow(np.clip(mask_rgb, 0, 1))\n",
"axes[1].set_title(\"Orientation-exclusive reciprocal masks\")\n",
"for axis in axes:\n",
" axis.set_axis_off()\n",
"plt.show()"
]
},
{
"cell_type": "code",
"execution_count": 36,
"id": "47070371",
"metadata": {
"execution": {
"iopub.execute_input": "2026-09-28T10:02:12.012144Z",
"iopub.status.busy": "2026-09-28T10:02:12.012061Z",
"iopub.status.idle": "2026-09-28T10:02:15.084841Z",
"shell.execute_reply": "2026-09-28T10:02:15.084581Z"
}
},
"outputs": [
{
"name": "stdout",
"output_type": "stream",
"text": [
"Saved (30, 749, 753, 3) RGB stack to /home/swim/sandbox/silicon-grain-boundaries/colored_grains_by_layer.tiff\n"
]
},
{
"data": {
"image/png": "iVBORw0KGgoAAAANSUhEUgAAEFEAAAhWCAYAAAAqk/AiAAAAOnRFWHRTb2Z0d2FyZQBNYXRwbG90bGliIHZlcnNpb24zLjEwLjYsIGh0dHBzOi8vbWF0cGxvdGxpYi5vcmcvq6yFwwAAAAlwSFlzAAAuIwAALiMBeKU/dgABAABJREFUeJzs3XmcHUW9Pv6nqvucM2v2nUAS9kR2CBBANoGwCF4IXL8qAgoICIKCV1EREOWC1+V3ZRG4IKC4IfsOgomyBAgJYRMSCAnZEybJ7HO27qrfH9Xdp/ucPjNnlmQmk+f9egVmerqrq7fqqk9XVwuttQYRERERERERERERERERERERERERERERERERERERERER0VZO9ncGiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIj6AgdRICIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIqJBgYMoEBEREREREREREREREREREREREREREREREREREREREdGgwEEUiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiGhQ4CAKRERERERERERERERERERERERERERERERERERERERERDQocBAFIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIhoUOIgCEREREREREREREREREREREREREREREREREREREREREQ0KHESBiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiAYFDqJARERERERERERERERERERERERERERERERERERERERERIMCB1EgIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiokGBgygQERERERERERERERERERERERERERERERERERERERER0aDAQRSIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIaFDgIApERERERERERERERERERERERERERERERERERERERERENChwEAUiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiGhQ4iAIRERERERERERERERERERERERERERERERERERERERERDQocRIGIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIBgUOokBEREREREREREREREREREREREREREREREREREREREREgwIHUSAiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiKiQYGDKBARERERERERERERERERERERERERERERERERERERERHRoMBBFIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIhoUOAgCkREREREREREREREREREREREREREREREREREREREREQ0KHAQBSIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIaFDiIAhERERERERERERERERERERERERERERERERERERERERENChxEgYiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIgGBQ6iQERERERERERERERERERERERERERERERERERERERERESDAgdRICIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIqJBgYMoEBEREREREREREREREREREREREREREREREREREREREdGgwEEUiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiGhQ4CAKRERERERERERERERERERERERERERERERERERERERERDQocBAFIqIB6Mgjj4QQIvh35JFH9neWqJsmT54cOYbnnHPOZl/nP//5z8g6hRD45z//udnXS0RERERERAMb4wxbP8YZiIiIiIiIqK8wTrD1Y5yAiIiIiIiIBgrGGbZ+jDMQERHRYGb3dwaIiHpKa40PP/wQS5YswcqVK9Ha2opsNov6+noMHz4cI0aMwB577IHJkyf3d1aJiGgQUEph4cKFeO+99/Dpp58im82irq4OU6ZMwf7774+JEyf2dxaJiIiIqBcYZyAiov6yatUqvPPOO/jkk0/Q0tICpRTq6uowYcIE7LTTTpg2bRpSqVR/Z5OIiIhom8I4ARERbQtaW1vx73//Gx999BEaGxvR2tqK2tpaDB8+HNtttx2mT5+OoUOH9nc2iYiIiLZ6jDMQEREREfUPDqJARFuV9vZ2PPjgg3j44Yfxr3/9C83NzV0uM2rUKBx00EGYNWsWZs2ahSFDhmyBnBIR0WCxevVq/PKXv8R9992HjRs3lp1v3333xcUXX4xzzjkHlmVtwRwSERERUU8xzkBERP1l7dq1uPPOO/HnP/8Zixcv7nTeZDKJffbZB8cddxxOO+007Lvvvlsol0RERETbFsYJiIhoS3JdFx988AHmz58f/Hv77beRyWQi811zzTW49tpr+2Sdzc3NePbZZzFnzhzMmTMHH374YafzCyGwxx574KyzzsLXv/51jBgxok/yQURERLQtYJyBiIi2pC0ZZ7j22mvxk5/8pFdpdEZrvdnSJqJtDwdRIKKtQnt7O37+85/jlltuQWNjY7eW3bBhA5566ik89dRTuPjii3H66afjmmuuwU477bSZcktERIPFHXfcge9+97toa2vrct6FCxfivPPOw80334z7778fu+222xbIIRERERH1BOMMRETUX3K5HK6//nr88pe/REdHR8XLzJs3D/PmzcOrr76KF154YTPnkoiIiGjbwjgBERFtCY7j4C9/+QsWLFiA+fPnY+HChRXHBnrr8ccfx5133om///3vyOVyFS+ntca7776L//qv/8I111yDn/3sZ7jssssgpdyMuSUiIiLaujHOQEREW0J/xhmIiLYmjGQS0YD3/PPPY9ddd8VPf/rTbgcSiqXTadx3332YOnUqLr300pIRtYiIiHzf/e53ceGFF1Y0gELY22+/jQMPPBCvvfbaZsoZEREREfUG4wxERNRfVq1ahYMPPhjXXXcdOy8QERERDRCMExAR0ZbS1taGs846C7/5zW/wyiuvbNHYwK9//Ws8+eST3RpAoVhHRwcuv/xyHHfccWhvb+/D3BERERENHowzEBHRltKfcYbNacaMGf2dBSIaZOz+zgARUWduvPFG/PCHP4TWOvbvw4cPx+c+9zlMmzYNo0ePxujRo6G1RlNTE5YtW4b58+fj1VdfRTqdjiyXz+dx88034/LLL8fkyZO3wJYQEdHW5Be/+AV+9atflUyvra3Fl770Jey1114YNWoUVq5ciZdeeglPPfVU5F7V0tKCE088EQsWLMCUKVO2ZNaJiIiIqBOMMxARUX9ZtmwZDj/8cKxatarkb8OHD8fxxx+PvffeG2PGjEFdXR2am5uxevVqvPPOO3j55Zfx6aef9kOuiYiIiAY3xgmIiGhbN2zYMBx66KE44IADMGbMGIwaNQodHR1YtmwZZs+ejZdffrlkmX/84x845ZRT8PTTTyOVSvVDromIiIgGJsYZiIhoWzFu3DjsvffevU7n7bffLpl23nnn9TpdIqIwDqJARAPWVVddheuvvz72bzNnzsRVV12FGTNmwLKsTtPp6OjAE088gZtvvhmvvPLK5sgqERENIm+++SZ++MMflkz/whe+gHvuuQfDhw+PTP/e976H999/H6eeeio+/PDDYHpjYyO+/OUvY+7cuRBCbPZ8ExEREVHnGGcgIqL+0tjYiJkzZ5YMoDB27FjceOONOPPMM2Hb5R/Zaa3xxhtv4A9/+APWrVu3ubNLREREtE1gnICIiAaS8ePHY//994dSCk8//fRmXdfQoUPxxS9+Eeeccw4OOuggSClj5/vJT36Ct956C+eddx4WLFgQ+dvs2bNxww034Nprr92seSUiIiLaWjDOQEREA8nmjjNceOGFuPDCC3uVxmuvvYYZM2ZEptXV1eGLX/xir9IlIioWH/0kIupnd911V2wgYfz48Zg9ezaeffZZHHbYYV0GEgCgpqYGX/ziF/Hyyy/jH//4B/bYY4/NkWUiIhokvvWtb8FxnMi0M844Aw8//HDJAAq+adOmYe7cudh5550j01977TXce++9myurRERERFQhxhmIiKg/nXfeefjoo48i0w4++GAsWrQI55xzTqcDKACAEAIHHnggbrnlFjzwwAObM6tERERE2wTGCYiIqD+NHj0axx9/PK666io8+uijWLVqFdasWYMnnngCZ5xxxmZb7/jx4/Gb3/wGa9euxR133IEZM2aUHUDBt88++2Du3Lk4/vjjS/7285//vGTASCIiIqJtEeMMRETUn/orztBbd911V8m0//f//h9qa2v7ITdENJhxEAUiGnA++OADfOtb3yqZvttuu2Hu3Lk46qijepz20UcfjQULFuC73/1ub7JIRESD1OzZszF37tzItLFjx+L222/vsvPAyJEjcffdd5fM99///d9QSvV5XomIiIioMowzEBFRf3rkkUfw8MMPR6bts88+eO655zBs2LBupyeE6KOcEREREW2bGCcgIqL+Ul9fj08++QSffvopnnnmGfz0pz/FF77wBWy33Xabfd1XXnklli5diksvvRTV1dXdWjaZTOKBBx7A+PHjI9MzmQwHeyQiIqJtHuMMRETUX/ozztBb7e3tuP/++0umn3feef2QGyIa7DiIAhENOBdeeCEymUxk2ogRIzB79mxMnjy51+knk0n84he/wJ///Gckk8lep0dERIPHbbfdVjLte9/7HkaMGFHR8p/97Gdx4oknRqYtWbIEzz//fJ/kj4iIiIi6j3EGIiLqL0opfO9734tMs20bd999N4YMGdJPuSIiIiLatjFOQERE/cWyLEyaNKlf1n388cejqqqqx8vX1dXFvrz35JNP9iZbRERERFs9xhmIiKi/9Gecobfuv/9+tLW1RabtscceOOigg/opR0Q0mNn9nQEiorCnn34aL774Ysn03/72t5gwYUKfrutLX/pSj5ft6OjA66+/jjVr1qChoQGZTAajR4/Line truncated
"text/plain": [
"<Figure size 4200x2100 with 8 Axes>"
]
},
"metadata": {},
"output_type": "display_data"
}
],
"source": [
"from scipy import fft as scipy_fft\n",
"\n",
"\n",
"# Reconstruct every accepted orientation independently in every z layer.\n",
"# scipy.fft keeps float32 input as complex64, limiting peak memory usage.\n",
"orientation_amplitudes = np.empty(\n",
" (len(orientation_fits), *coloring_volume.shape), dtype=np.float32\n",
")\n",
"for layer_index, layer in enumerate(coloring_volume):\n",
" layer_fft = scipy_fft.fftshift(scipy_fft.fft2(layer))\n",
" for orientation_index, mask in enumerate(orientation_masks):\n",
" filtered = scipy_fft.ifft2(scipy_fft.ifftshift(layer_fft * mask))\n",
" orientation_amplitudes[orientation_index, layer_index] = np.abs(filtered)\n",
"\n",
"# A global robust scale per orientation preserves brightness consistency in z.\n",
"for orientation_index in range(len(orientation_fits)):\n",
" amplitude = orientation_amplitudes[orientation_index]\n",
" low, high = np.percentile(amplitude, (2, 99.7))\n",
" if high <= low:\n",
" orientation_amplitudes[orientation_index].fill(0)\n",
" else:\n",
" orientation_amplitudes[orientation_index] = np.clip(\n",
" (amplitude - low) / (high - low), 0, 1\n",
" )\n",
"\n",
"# The original signal provides continuous atom support and suppresses Fourier\n",
"# ringing. Orientation contributions remain additive, so boundary atoms mix.\n",
"atom_support = np.empty_like(coloring_volume)\n",
"for layer_index, layer in enumerate(coloring_volume):\n",
" low, high = np.percentile(layer, (5, 99.5))\n",
" if high <= low:\n",
" atom_support[layer_index].fill(0)\n",
" else:\n",
" atom_support[layer_index] = np.clip((layer - low) / (high - low), 0, 1) ** 1.5\n",
"\n",
"colored_grain_float = np.zeros((*coloring_volume.shape, 3), dtype=np.float32)\n",
"for amplitude, color in zip(orientation_amplitudes, grain_colors):\n",
" colored_grain_float += amplitude[..., None] * color\n",
"colored_grain_float = np.clip(colored_grain_float, 0, 1)\n",
"colored_grain_float *= atom_support[..., None]\n",
"colored_grain_stack = np.round(255 * colored_grain_float).astype(np.uint8)\n",
"\n",
"colored_grain_path = os.path.abspath(\"colored_grains_by_layer.tiff\")\n",
"tifffile.imwrite(\n",
" colored_grain_path,\n",
" colored_grain_stack,\n",
" photometric=\"rgb\",\n",
" metadata={\"axes\": \"ZYXS\"},\n",
")\n",
"print(f\"Saved {colored_grain_stack.shape} RGB stack to {colored_grain_path}\")\n",
"\n",
"# Preview representative layers and a maximum-color projection.\n",
"preview_layers = np.unique(\n",
" np.linspace(0, coloring_volume.shape[0] - 1, 6).round().astype(int)\n",
")\n",
"fig, axes = plt.subplots(2, 4, figsize=(14, 7), constrained_layout=True, dpi=300)\n",
"for axis, layer_index in zip(axes.flat, preview_layers):\n",
" axis.imshow(colored_grain_stack[layer_index])\n",
" axis.set_title(f\"Colored layer z={layer_index}\")\n",
" axis.set_axis_off()\n",
"\n",
"axes.flat[-2].imshow(colored_grain_stack.max(axis=0))\n",
"axes.flat[-2].set_title(\"RGB maximum projection\")\n",
"axes.flat[-2].set_axis_off()\n",
"axes.flat[-1].imshow(\n",
" fit_image,\n",
" cmap=\"gray\",\n",
" vmin=np.percentile(fit_image, 2),\n",
" vmax=np.percentile(fit_image, 99.7),\n",
")\n",
"axes.flat[-1].set_title(\"Corrected summed projection\")\n",
"axes.flat[-1].set_axis_off()\n",
"plt.show()"
]
}
],
"metadata": {
"kernelspec": {
"display_name": "lemon",
"language": "python",
"name": "python3"
},
"language_info": {
"codemirror_mode": {
"name": "ipython",
"version": 3
},
"file_extension": ".py",
"mimetype": "text/x-python",
"name": "python",
"nbconvert_exporter": "python",
"pygments_lexer": "ipython3",
"version": "3.12.12"
}
},
"nbformat": 4,
"nbformat_minor": 5
}