mirror of
https://github.com/c-sooyoung/lemon-sandbox.git
synced 2026-11-16 04:07:59 +09:00
3.7 MiB
3.7 MiB
In [1]:
import os
import numpy as np
from matplotlib import pyplot as plt, colormaps
import tifffileIn [2]:
basedir = "/home/swim/shared/Si_project/260707_BCAT_tif_file"
os.listdir(basedir)Out [2]:
['f_410_1_obj_phase_roi_Niter100.tiff', 'f_410_2_obj_phase_roi_Niter100.tiff', 'f_410_3_obj_phase_roi_Niter100.tiff', 'f_410_4_obj_phase_roi_Niter100.tiff', 'f_420_1_obj_phase_roi_Niter120.tiff', 'f_420_2_obj_phase_roi_Niter120.tiff', 'f_420_3_obj_phase_roi_Niter120.tiff', 'f_420_4_obj_phase_roi_Niter120.tiff', 'f_430_1_obj_phase_roi_Niter80.tiff', 'f_430_2_obj_phase_roi_Niter80.tiff', 'f_430_3_obj_phase_roi_Niter80.tiff', 'f_430_4_obj_phase_roi_Niter80.tiff', 'f_450_1_obj_phase_roi_Niter90.tiff', 'f_450_2_obj_phase_roi_Niter90.tiff', 'f_450_3_obj_phase_roi_Niter90.tiff', 'f_450_4_obj_phase_roi_Niter90.tiff', 'f_471_1_obj_phase_roi_Niter50.tiff', 'f_471_2_obj_phase_roi_Niter50.tiff', 'f_471_3_obj_phase_roi_Niter50.tiff', 'f_471_4_obj_phase_roi_Niter50.tiff', 'f_471_5_obj_phase_roi_Niter230.tiff', 'f_471_6_obj_phase_roi_Niter230.tiff', 'f_471_7_obj_phase_roi_Niter230.tiff', 'f_472_1_obj_phase_roi_Niter50.tiff', 'f_472_2_obj_phase_roi_Niter50.tiff', 'f_472_3_obj_phase_roi_Niter50.tiff', 'f_472_4_obj_phase_roi_Niter50.tiff']
In [5]:
tiff = 'f_430_2_obj_phase_roi_Niter80.tiff'
image = tifffile.imread(os.path.join(basedir, tiff))
roi_A = 92
A_per_px = roi_A / image.shape[0] # A/pixel
Si_distance_px = 1.358 / A_per_px
print(f"Shape : {image.shape}")
print(f"Dtype : {image.dtype}")
print(f"H × W : {image.shape[0]} × {image.shape[1]}")
print(f"Pixel Size : {A_per_px:.2f} A/px")
# fig, ax = plt.subplots(figsize=(7, 7))
# ax.imshow(image[image.shape[0] // 2], cmap='gray')
# ax.set_title(f"Raw Image ({image.shape[0]} × {image.shape[1]} px)")
# ax.axis('on') # 픽셀 좌표 눈금 표시
# plt.tight_layout()
# plt.show()Shape : (30, 379, 379) Dtype : uint8 H × W : 30 × 379 Pixel Size : 3.07 A/px
In [9]:
fig, axs = plt.subplots(1, 2, figsize=(6, 3))
ax1, ax2 = axs
layer = image[3]
ax1.imshow(layer)
ax1.axis('off')
ax2.imshow(np.log1p(abs(np.fft.fftshift(np.fft.fft2(np.fft.ifftshift(layer))))))
ax2.axis('off')Out [9]:
(np.float64(-0.5), np.float64(378.5), np.float64(378.5), np.float64(-0.5))
In [11]:
# Match the FFT to an ideal diamond-cubic Si [110] diffraction grid.
# For zone axis [110], h + k = 0. The supplied fcc/diamond extinction
# rules leave: (h, -h, l), with h and l both odd, or both even with l = 4n.
layer = image[3].astype(float)
height, width = layer.shape
A_per_px_xy = roi_A / height
d_004_A = 1.358 # given above; for Si, d_004 = a / 4
a_si_A = 4 * d_004_A
fft_shifted = np.fft.fftshift(np.fft.fft2(layer))
spectrum = np.log1p(np.abs(fft_shifted))
centre_y, centre_x = (np.asarray(layer.shape) - 1) / 2
yy, xx = np.ogrid[:height, :width]
radius_px = np.hypot(xx - centre_x, yy - centre_y)
# Detect candidate Bragg maxima once. Keep only the strongest candidates so
# the rotation/scale fit is driven by diffraction peaks rather than background.
neighbourhood = 3
local_maximum = spectrum.copy()
for dy in range(-neighbourhood, neighbourhood + 1):
for dx in range(-neighbourhood, neighbourhood + 1):
local_maximum = np.maximum(local_maximum, np.roll(np.roll(spectrum, dy, 0), dx, 1))
candidate_pixels = np.argwhere((spectrum == local_maximum) & (radius_px > 8))
candidate_strength = spectrum[candidate_pixels[:, 0], candidate_pixels[:, 1]]
strongest = np.argsort(candidate_strength)[-min(600, len(candidate_pixels)):]
candidate_pixels = candidate_pixels[strongest]
candidate_strength = candidate_strength[strongest]
# Build all allowed reciprocal-lattice points in the [110] zone. Coordinates
# are expressed along [1 -1 0] (horizontal) and [001] (vertical).
grid_hkls, grid_xy = [], []
base_scale_px = width * A_per_px_xy / a_si_A # pixels per reciprocal 1/a
for h in range(-12, 13):
for l in range(-12, 13):
if h == 0 and l == 0:
continue
all_odd = (h % 2 != 0) and (l % 2 != 0)
diamond_even = (h % 2 == 0) and (l % 2 == 0) and (l % 4 == 0)
if all_odd or diamond_even:
grid_hkls.append((h, -h, l))
grid_xy.append((np.sqrt(2) * h * base_scale_px, l * base_scale_px))
grid_hkls = np.asarray(grid_hkls)
grid_xy = np.asarray(grid_xy)
match_tolerance_px = 4.5
def projected_grid(angle_deg, scale_factor):
angle = np.deg2rad(angle_deg)
rotation = np.array([[np.cos(angle), -np.sin(angle)],
[np.sin(angle), np.cos(angle)]])
xy = scale_factor * grid_xy @ rotation.T
pixels = np.column_stack((centre_y + xy[:, 1], centre_x + xy[:, 0]))
valid = ((pixels[:, 0] >= 4) & (pixels[:, 0] < height - 4)
& (pixels[:, 1] >= 4) & (pixels[:, 1] < width - 4))
return grid_hkls[valid], pixels[valid]
def grid_score(angle_deg, scale_factor):
_, targets = projected_grid(angle_deg, scale_factor)
distance2 = ((targets[:, None, :] - candidate_pixels[None, :, :]) ** 2).sum(axis=2)
nearest = np.argmin(distance2, axis=1)
# Gaussian distance weighting rewards agreement with many allowed spots.
return np.sum(candidate_strength[nearest] * np.exp(-distance2[np.arange(len(targets)), nearest]
/ (2 * match_tolerance_px ** 2)))
# Fit detector rotation and a small scale correction to the measured FFT.
best_score, best_angle, best_scale = -np.inf, None, None
for scale in np.linspace(0.94, 1.06, 7):
for angle in np.arange(0, 180, 2):
score = grid_score(angle, scale)
if score > best_score:
best_score, best_angle, best_scale = score, angle, scale
for scale in np.linspace(best_scale - 0.015, best_scale + 0.015, 7):
for angle in np.linspace(best_angle - 2, best_angle + 2, 17):
score = grid_score(angle, scale)
if score > best_score:
best_score, best_angle, best_scale = score, angle, scale
# Retain only observed candidate peaks that are close to allowed, fitted-grid sites.
fitted_hkls, fitted_pixels = projected_grid(best_angle, best_scale)
distance2 = ((fitted_pixels[:, None, :] - candidate_pixels[None, :, :]) ** 2).sum(axis=2)
nearest = np.argmin(distance2, axis=1)
matched = []
used_candidates = set()
for hkl, distance, candidate_index in zip(fitted_hkls, distance2[np.arange(len(fitted_pixels)), nearest], nearest):
if distance <= match_tolerance_px ** 2 and int(candidate_index) not in used_candidates:
matched.append((hkl, candidate_pixels[candidate_index]))
used_candidates.add(int(candidate_index))
if not matched:
raise RuntimeError('The fitted Si [110] grid did not match any FFT candidates.')
mask_radius_px = 6
si110_grid_mask = np.zeros(layer.shape, dtype=bool)
for _, (y, x) in matched:
si110_grid_mask |= (xx - x) ** 2 + (yy - y) ** 2 <= mask_radius_px ** 2
si110_grid_fft = fft_shifted * si110_grid_mask
si110_grid_image = np.fft.ifft2(np.fft.ifftshift(si110_grid_fft)).real
fig, axes = plt.subplots(1, 4, figsize=(16, 4), constrained_layout=True)
axes[0].imshow(layer, cmap='gray'); axes[0].set_title('Input layer')
axes[1].imshow(spectrum, cmap='magma')
axes[1].set_title(f'Fitted ideal Si [110] grid\nrotation={best_angle:.2f}°, scale={best_scale:.3f}')
axes[2].imshow(np.log1p(np.abs(si110_grid_fft)), cmap='magma', vmin=0, vmax=spectrum.max())
axes[2].set_title(f'Matched allowed peaks ({len(matched)})')
axes[3].imshow(si110_grid_image, cmap='gray'); axes[3].set_title('Grid-matched inverse FFT')
for hkl, pixel in zip(fitted_hkls, fitted_pixels):
axes[1].plot(pixel[1], pixel[0], 'w+', ms=4, mew=0.7, alpha=0.45)
for hkl, (y, x) in matched:
axes[1].plot(x, y, 'co', ms=6, mfc='none', mew=1.2)
for ax in axes:
ax.axis('off')
plt.show()In [12]:
# Second-dominant Si [110] grain. Run the preceding grid-fit cell first.
# Keep the first grain's calibrated grid scale fixed; search only its rotation.
# Remove every candidate close to the first orientation's full ideal grid.
first_grid_pixels = fitted_pixels
distance2_to_first = ((candidate_pixels[:, None, :] - first_grid_pixels[None, :, :]) ** 2).sum(axis=2)
keep_residual = distance2_to_first.min(axis=1) > (2 * match_tolerance_px) ** 2
second_candidate_pixels = candidate_pixels[keep_residual]
second_candidate_strength = candidate_strength[keep_residual]
if len(second_candidate_pixels) == 0:
raise RuntimeError('No FFT candidates remain after excluding the first grid.')
second_scale = best_scale
def second_grid_score(angle_deg):
_, targets = projected_grid(angle_deg, second_scale)
distance2 = ((targets[:, None, :] - second_candidate_pixels[None, :, :]) ** 2).sum(axis=2)
nearest = np.argmin(distance2, axis=1)
nearest_distance2 = distance2[np.arange(len(targets)), nearest]
matches = nearest_distance2 <= match_tolerance_px ** 2
return np.sum(second_candidate_strength[nearest[matches]]
* (1 - nearest_distance2[matches] / match_tolerance_px ** 2))
# Coarse-to-fine rotation-only fit of the strongest remaining Si [110] grid.
second_score, second_angle = -np.inf, None
for angle in np.arange(0, 180, 1):
score = second_grid_score(angle)
if score > second_score:
second_score, second_angle = score, angle
for angle in np.linspace(second_angle - 1, second_angle + 1, 41):
score = second_grid_score(angle)
if score > second_score:
second_score, second_angle = score, angle
second_hkls, second_grid_pixels = projected_grid(second_angle, second_scale)
relative_rotation = (second_angle - best_angle + 90) % 180 - 90
distance2 = ((second_grid_pixels[:, None, :] - second_candidate_pixels[None, :, :]) ** 2).sum(axis=2)
nearest = np.argmin(distance2, axis=1)
second_matched, used_candidates = [], set()
for hkl, distance, candidate_index in zip(second_hkls, distance2[np.arange(len(second_grid_pixels)), nearest], nearest):
if distance <= match_tolerance_px ** 2 and int(candidate_index) not in used_candidates:
second_matched.append((hkl, second_candidate_pixels[candidate_index]))
used_candidates.add(int(candidate_index))
if not second_matched:
raise RuntimeError('No second Si [110] grid peaks matched the residual FFT.')
# Pass every allowed peak in the fitted second-grain grid, including weak sites
# that were not strong enough to be used as orientation-matching candidates.
second_mask = np.zeros(layer.shape, dtype=bool)
for y, x in second_grid_pixels:
second_mask |= (xx - x) ** 2 + (yy - y) ** 2 <= mask_radius_px ** 2
second_fft = fft_shifted * second_mask
second_grain_image = np.fft.ifft2(np.fft.ifftshift(second_fft)).real
fig, axes = plt.subplots(1, 4, figsize=(16, 4), constrained_layout=True)
axes[0].imshow(layer, cmap='gray'); axes[0].set_title('Input layer')
axes[1].imshow(spectrum, cmap='magma')
axes[1].set_title(f'Rotated second Si [110] grid\nΔrotation={relative_rotation:.2f}°')
axes[2].imshow(np.log1p(np.abs(second_fft)), cmap='magma', vmin=0, vmax=spectrum.max())
axes[2].set_title(f'All allowed second-grid peaks ({len(second_grid_pixels)})')
axes[3].imshow(second_grain_image, cmap='gray'); axes[3].set_title('Second-grain inverse FFT')
for pixel in first_grid_pixels:
axes[1].plot(pixel[1], pixel[0], 'w+', ms=3, mew=0.5, alpha=0.18)
for pixel in second_grid_pixels:
axes[1].plot(pixel[1], pixel[0], 'y+', ms=4, mew=0.8, alpha=0.5)
for _, (y, x) in second_matched:
axes[1].plot(x, y, 'co', ms=6, mfc='none', mew=1.2)
for ax in axes:
ax.axis('off')
plt.show()In [22]:
# Composite of the two fitted Si [110] grain reconstructions.
# Run the first-grid and second-grid cells before this cell.
def display_strength(filtered_image):
amplitude = np.abs(filtered_image)
low, high = np.percentile(amplitude, (1, 99.5))
if high == low:
return np.zeros_like(amplitude)
return np.clip((amplitude - low) / (high - low), 0, 1)
first_blue = display_strength(si110_grid_image)
second_red = display_strength(second_grain_image)
# Use the original layer as a real-space support mask. Pixels below this
# contrast level cannot receive Fourier-filtered color.
mask_low, mask_high = np.percentile(layer, (5, 95))
support_threshold = 0.5 # increase to reject more low-signal pixels
if mask_high == mask_low:
original_mask = np.ones(layer.shape, dtype=bool)
else:
original_support = np.clip((layer - mask_low) / (mask_high - mask_low), 0, 1)
original_mask = original_support >= support_threshold
composite = np.zeros((*layer.shape, 3))
composite[..., 0] = second_red # second orientation: red
composite[..., 1] = first_blue # first orientation: blue
composite *= original_mask[..., None]
first_rgb = np.zeros_like(composite)
first_rgb[..., 1] = first_blue
first_rgb *= original_mask[..., None]
second_rgb = np.zeros_like(composite)
second_rgb[..., 0] = second_red
second_rgb *= original_mask[..., None]
fig, axes = plt.subplots(1, 4, figsize=(18, 5), constrained_layout=True)
axes[0].imshow(original_mask, cmap='gray'); axes[0].set_title('Original-layer support mask')
axes[1].imshow(first_rgb); axes[1].set_title('First orientation — blue')
axes[2].imshow(second_rgb); axes[2].set_title('Second orientation — red')
axes[3].imshow(composite); axes[3].set_title('Masked composite (overlap = magenta)')
for ax in axes:
ax.axis('off')
plt.show()In [ ]:
In [24]:
# Color the complete TIFF stack using the two orientations fitted on the reference layer.
# Run the first-grid and second-grid cells before this cell.
volume = image.astype(float)
# Fixed reciprocal-space masks from the reference layer are applied to every TIFF layer.
fft_volume = np.fft.fftshift(np.fft.fft2(volume, axes=(-2, -1)), axes=(-2, -1))
first_volume = np.fft.ifft2(
np.fft.ifftshift(fft_volume * si110_grid_mask[None, :, :], axes=(-2, -1)), axes=(-2, -1)
).real
second_volume = np.fft.ifft2(
np.fft.ifftshift(fft_volume * second_mask[None, :, :], axes=(-2, -1)), axes=(-2, -1)
).real
def normalize_volume(filtered_volume):
amplitude = np.abs(filtered_volume)
low, high = np.percentile(amplitude, (1, 99.5))
if high == low:
return np.zeros_like(amplitude)
return np.clip((amplitude - low) / (high - low), 0, 1)
first_blue_volume = normalize_volume(first_volume)
second_red_volume = normalize_volume(second_volume)
# Build an original-image support mask per layer to suppress Fourier ringing.
stack_support_threshold = globals().get('support_threshold', 0.5)
support_volume = np.zeros(volume.shape, dtype=bool)
for index, original_layer in enumerate(volume):
low, high = np.percentile(original_layer, (5, 95))
if high == low:
support_volume[index] = True
else:
scaled = np.clip((original_layer - low) / (high - low), 0, 1)
support_volume[index] = scaled >= stack_support_threshold
colored_stack = np.zeros((*volume.shape, 3), dtype=np.uint8)
colored_stack[..., 0] = np.round(255 * second_red_volume * support_volume).astype(np.uint8) # second: red
colored_stack[..., 1] = np.round(255 * first_blue_volume * support_volume).astype(np.uint8) # first: blue
output_path = os.path.abspath('colored.tiff')
tifffile.imwrite(output_path, colored_stack, photometric='rgb', metadata={'axes': 'ZYXS'})
print(f'Saved {colored_stack.shape} RGB stack to {output_path}')
reference_index = image.shape[0] // 2
plt.figure(figsize=(6, 6))
plt.imshow(colored_stack[reference_index])
plt.title(f'Colored reference layer {reference_index}')
plt.axis('off')
plt.show()Saved (32, 379, 379, 3) RGB stack to /home/swim/sandbox/silicon-grain-boundaries/colored.tiff
In [70]:
# RGBA version: use the original TIFF intensity as continuous alpha, not a cutoff mask.
# Run the two orientation-filter cells first. Output: ./colored_alpha.tiff
volume = image.astype(float)
fft_volume = np.fft.fftshift(np.fft.fft2(volume, axes=(-2, -1)), axes=(-2, -1))
first_volume = np.fft.ifft2(
np.fft.ifftshift(fft_volume * si110_grid_mask[None, :, :], axes=(-2, -1)), axes=(-2, -1)
).real
second_volume = np.fft.ifft2(
np.fft.ifftshift(fft_volume * second_mask[None, :, :], axes=(-2, -1)), axes=(-2, -1)
).real
def normalize_for_display(values, percentiles=(1, 99.5)):
low, high = np.percentile(values, percentiles)
if high == low:
return np.zeros_like(values)
return np.clip((values - low) / (high - low), 0, 1)
# RGB encodes filtered orientation strength; alpha continuously follows original intensity.
first_blue_volume = normalize_for_display(np.abs(first_volume))
second_red_volume = normalize_for_display(np.abs(second_volume))
original_alpha = beta.cdf(normalize_for_display(volume), 3, 8)
colored_alpha_stack = np.zeros((*volume.shape, 3), dtype=np.uint8)
colored_alpha_stack[..., 0] = np.round(255 * (second_red_volume * (original_alpha))).astype(np.uint8) # second: red
colored_alpha_stack[..., 1] = np.round(255 * (first_blue_volume * (original_alpha))).astype(np.uint8) # first: blue
# colored_alpha_stack[..., 2] = np.ones_like(original_alpha) * 255
# colored_alpha_stack[..., 3] = np.round(255 * (original_alpha)).astype(np.uint8) # original: alpha
output_path = os.path.abspath('colored_alpha.tiff')
tifffile.imwrite(
output_path, colored_alpha_stack, photometric='rgb', extrasamples='unassalpha',
metadata={'axes': 'ZYXS'},
)
print(f'Saved {colored_alpha_stack.shape} RGBA stack to {output_path}')
reference_index = image.shape[0] // 2
fig, axes = plt.subplots(1, 2, figsize=(10, 5), constrained_layout=True)
axes[0].imshow(original_alpha[reference_index], cmap='gray', vmin=0, vmax=1)
axes[0].set_title('Original layer used as alpha')
axes[1].imshow(colored_alpha_stack[reference_index])
axes[1].set_title('RGBA orientation composite')
for ax in axes:
ax.axis('off')
plt.show()Saved (32, 379, 379, 3) RGBA stack to /home/swim/sandbox/silicon-grain-boundaries/colored_alpha.tiff
In [69]:
from scipy.stats import beta
x = np.linspace(0, 1)
plt.plot(x, beta.cdf(x, 4, 8))Out [69]:
[<matplotlib.lines.Line2D at 0x7f03c9cd2270>]