Imaging refactor part 2 - Atoms: add_atoms(), refine_atoms(), plot(kind='atoms') and tests#236
Imaging refactor part 2 - Atoms: add_atoms(), refine_atoms(), plot(kind='atoms') and tests#236darshan-mali wants to merge 11 commits into
Conversation
| relative to the lattice origin r0 and basis vectors (u, v), and are used to tile the | ||
| image with candidate atom centers at all visible integer translations. | ||
| numbers : array-like of int, shape (S,), optional | ||
| Identifier per site (e.g., species or label). If None, uses 1..S. Used only for plotting |
There was a problem hiding this comment.
If None, it appears to use 0, 1, ..., S-1
There was a problem hiding this comment.
Fixed this to be consistent
| # VALIDATION: Check that lattice vectors have been defined | ||
| if not hasattr(self, "_lat") or self._lat is None: | ||
| raise ValueError( | ||
| "Lattice vectors have not been fitted. Please call define_lattice() first." |
There was a problem hiding this comment.
define_lattice() --> define_lattice_vectors()
| r_px = float(intensity_radius) if intensity_radius is not None else _auto_radius_px() | ||
|
|
||
| # Annulus radii for background contrast measurement (in pixels) | ||
| rin, rout = (1.5 * r_px, 3.0 * r_px) if annulus_radii is None else annulus_radii |
There was a problem hiding this comment.
Is this what we want? It seems like this ring will include neighboring atoms... This could cause issues for atom position refinement.
There was a problem hiding this comment.
Addressing this in main comment.
| from scipy.ndimage import distance_transform_edt | ||
|
|
||
| DT = distance_transform_edt(m) | ||
| except Exception: |
There was a problem hiding this comment.
not sure why the distance_transform_edt would fail, but if it does, perhaps add an output message for this? otherwise this is a silent failure
There was a problem hiding this comment.
This is just a precautionary try except block for all scipy/matrix calculations. Added a warning to let the user know the error occurred and now defaulting to pixel-level mask checking
| # Compute mean intensity in the detection disk for all candidates | ||
| int_center = np.empty(xy.shape[0], dtype=float) | ||
| for i in range(xy.shape[0]): | ||
| int_center[i] = mean_disk(x[i], y[i]) |
There was a problem hiding this comment.
we compute mean intensity for all atoms, even though we have a validity mask (we could generate keep right before this) that should rule some out at this point. It probably doesn't cost too much in the way of time, but it is worth making this change.
There was a problem hiding this comment.
Rearranged the order of operations to avoid unnecessary calculations
| estimate is invalid or non-positive, a robust fallback is used. | ||
| max_nfev : int, default 200 | ||
| Maximum number of function evaluations for the non-linear least-squares solver. | ||
| max_move_px : float, optional |
There was a problem hiding this comment.
max_move_px ends up not being the actual maximum movement, it is just the maximum movement in the x and y directions - both moving by max_move_px is not forbidden.
There was a problem hiding this comment.
Additional comment on this - I think that the maximum movement should not be greater than the window that the fit is over. The default is to make it equal, which seems okay, but user can set it greater; do we want to forbid that?
There was a problem hiding this comment.
Added a check for max_move_px > r_fit
Implementing a circular max_move_px limit would result in a significant increase in computation for a minimal increase in accuracy. Considering a square patch of side 2*r_fit is simple, fast and is accurate enough.
Also, since the default value of r_fit is 0.5 * nearest_neighbour_distance, it should avoid almost all overlaps. Even for a case when the nearest neighbour is along the diagonal, if the nearest_neighbour_distance is r, then r_fit = 0.5 * r, and the centre to corner distance for the patch would be r/sqrt(2) = 0.7 * r. This should avoid the neighbour influencing the fit especially for lattices without any point defects, as the neighbours on all corners would cancel each others effect and it would just contribute to the background, if any.
| max(pmax - pmin, amp0 * 4.0), | ||
| max(2.0 * r_fit, 1.0), | ||
| pmax + (pmax - pmin), | ||
| ] |
There was a problem hiding this comment.
these values seem arbitrary - why 0.25 for sigma min? why amp0*4 for amplitude max? I don't really have a better idea but just want to be thoughtful about these hard-coded values.
There was a problem hiding this comment.
Addressing this in main comment
| ) | ||
|
|
||
| assert result is simple_lattice | ||
|
|
There was a problem hiding this comment.
these tests seem okay, and pass, but there is no test that validates the found positions of the atoms after refinement. That would be a good test.
There was a problem hiding this comment.
Added additional pytest to generate synthetic data and check fit accuracy.
…fixed bug to avoid all NaN array.
… interatomic separation (auto). Also added sanity checks for radii.
|
@wwmills I have made all the changes that were requested. The changes are ready for review. Most of the comments were addressed directly in the same comment chain. There are 2 specific ones that I felt needed a more detailed explanation, which is why I am choosing to answer them here. I also found a bug where an array completely filled with np.nan could be passed through without any errors. Added a check and wrote a pytest to handle it. Regarding the Gaussian refinement parametersI tried to come up with multiple different edge cases in which the gaussian fitting could be used, and tried to push the parameters to values beyond which it becomes reasonably unrealistic to attempt to fit a Gaussian. The x and y initial guesses and bounds are correct and don't need any updates. The amplitude starts with an initial guess of pmax - bg0, and is constrained by 0 and pmax + (pmax - pmin). The 4.0*amp0 condition was removed as it was arbitrary, while this is a fixed value that no pixel in the patch exceeds. The background has an initial guess of the median of the part of the patch that is outside the circle of radius r_fit (essentially the region of the square not covered by the inscribed circle), floored by 0.0. It is constrained by the same values as the amplitude. Regarding sigma, there are a few different scenarios to consider.
Note: For points 2 and 3, this is the upper bound for sigma. If the initial guess is good enough, it should never reach close to this value for most cases. This is just an attempt to get the worst possible scenario in which this would work. Regarding Atom Detection radiiThe intensity radius (atomic disk) and the annulus radii (background annulus) parameters can now be passed as both pixels or in terms of the interatomic separation (auto_radius = 0.5 * interatomic separation), which is the more intuitive unit for the same. The defaults were changed to 0.8 * auto_radius for the disk and 1.0 - 1.7 * auto_radius for the annulus. These will depend on the sample, imaging conditions and image mode. This attempts to maximise the number of pixels in each while trying to avoid neighbouring atoms. The does a good enough job as seen in the figures below, however, it still does include small parts of the neighbouring atoms, but this is small enough to avoid any errors).
It is very hard to set default radii that satisfy all the criteria, especially since this code is primarily built to deal with polarization measurements which does involve displacement of atoms from expected positions. For the same reason, warnings were chosen instead of raising errors for when the disk radius exceeds 1.0 * auto_radius and when the outer annulus radius exceeds 2.0 * auto_radius, as the values chosen by the user could be more appropriate depending on the data. |






What does this PR do?
Part 2 of Imaging refactor
Enables the Lattice class to store atoms and their data. This includes
add_atoms()function that checks if an atom is present at a given position and stores the atomic positions in both pixel and unit cell coordinatesrefine_atoms()function to refine the atomic positionsplot()now includeskind='atoms'Relevant references
Part 1 can be found at : #198
This PR involves the use of the
Vectordatastructure.A tutorial for the same can be found at: https://github.com/electronmicroscopy/quantem-tutorials/blob/main/tutorials/core/vector.ipynb
API and logic
After defining the lattice vectors, atoms can be added to$0 \leq u,v < 1$ .
Latticevia theadd_atoms()method.The
add_atoms()takes in the fractional/unit cell coordinates of all atoms in one unit cellThese are tiled across all unit cells and checked against the background to determine if an atom is present.
The
refine_atoms()functions optimizes for the position of the atom via 2D Gaussian fitting.The pixel and unit cell coordinates of the atoms are stored in
Lattice.atomswhich is aVectordatastructure, alongwith other fitting information.Both
add_atoms()andrefine_atoms()set thedefault_plot='atoms'inLattice.plot().Files changed
Updated files
src/quantem/imaging/lattice.py: Theadd_atoms()andrefine_atoms()functions were added.src/quantem/imaging/lattice_visualization.py: Functionality forplot(kind='atoms')was added along with necessary helperstests/imaging/test_lattice.py: Basic pytests foradd_atoms()andrefine_atoms()based on expected user behaviourExamples
A testing notebook with examples can be found here:
Lattice_refactor_2_atoms.ipynb
(Note: Some plotting calls have been commented out to reduce file size. Please uncomment them before running)
Example code block:
Example output:

PR Checklist
Reviewer checklist