Skip to content

Conversation

@jokasimr
Copy link
Contributor

@jokasimr jokasimr commented Sep 10, 2025

Fixes #17

We don't have perfect requirements here yet, but I have some idea about what is needed, so started implementing that.

The "one number to estimate resolution" implemented is f_c the cutoff frequency of the MTF (modulation transfer function) and MTF50 the frequency where MTF decreases below 0.5.

@jokasimr
Copy link
Contributor Author

Example using fake data

Original image
Figure 3(1)

"Measured image" (fake obtained by convolution the ideal image by a point-spread-function)
Figure 7(1)

Computed modulation transfer function
Figure 23

we can see the cutoff frequency in this example is at about 0.2 lines per pixel.

@jokasimr jokasimr marked this pull request as ready for review September 10, 2025 13:01
@jokasimr
Copy link
Contributor Author

This still needs some more work - mainly applying it to real test data to check that it work in practice.

It also needs tests.

@YooSunYoung
Copy link
Member

As we discussed earlier, we should just assume the input is:

  • cropped normalized image
  • Perfect image of the siemens star
  • ROI of the siemens star image

or

  • cropped normalized image
  • cropped perfect image of the siemens star that aligns with the cropped normalized image

Then what the interface/notebook should do becomes quite simple right...?

@YooSunYoung
Copy link
Member

Also the resolution should be dependent on the wavelength in principal but this function doesn't care if its 2D or 3D right...? or should be broadcast the perfect image first...?

@jokasimr
Copy link
Contributor Author

cropped normalized image
cropped perfect image of the siemens star that aligns with the cropped normalized image

I think this makes most sense, the cropping can be done elsewhere.

If the resolution is different for different wavelengths then the resolution function can be called multiple times, once for each wavelength.

@jokasimr jokasimr requested a review from nvaytet September 29, 2025 07:09
@github-project-automation github-project-automation bot moved this to In progress in Development Board Oct 2, 2025
@jokasimr jokasimr moved this from In progress to Selected in Development Board Oct 2, 2025
Copy link
Member

@nvaytet nvaytet left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Changes look fine, but we should try and not have any 'magic' numbers without adding explanations about where they came from.

)


def _radial_profile(data: NDArray):
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Missing return type



def _radial_profile(data: NDArray):
'''Integrate ellipses around center of image.'''
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Does this mean we need to center the image before giving it to the function?
Can this be tricky in some cases?

Would passing the (x,y) coordinates of the center as arguments (we can set them to 0 by default) make things easier in some cases?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's not necessary, it's applied in the frequency domain and then we get a centered image automatically.

_measured = measured_image.values
# Can't do inplace because dtype of sum might be different from dtype of input
_measured = _measured / _measured.sum()
_reference = (open_beam_image * target).to(unit=measured_image.unit).values
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can you explain why we are multiplying target by open beam image?
Alternatively, can you add a link in the docstring where you got the implementation from (paper or online article).

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There is a motivation for the procedure on this page: https://git.esss.dk/dram/demo-drawer/odin-demo/-/blob/main/STAP-2025-fall/siemens-star-resolution-estimation.ipynb?ref_type=heads

Unfortunately because of gitlab rendering limits the page is not displayed correctly.

Here is a screenshot of the relevant section:

Screenshot from 2025-09-24 15-14-03

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can you add all this in the docstring? It would be useful to have all the math in the API reference docs.
It can be under a Notes section in the docstring if you like, so that we first have all the parameters and return types?

# a kind of counts I think in our unit system that is best
# represented as 'dimensionless'.
coords={'frequency': sc.linspace('frequency', 0, (1 / 2) ** 0.5, len(_mtf))},
# We're only interested in frequencies below 0.5 oscillations per pixel
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

As suggested above, a link to an article would help understand where the number 0.5 comes from.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

(1/2)**0.5 is the largest magnitude of the spatial frequency returned by the 2D FFT. It's one period per one diagonal step in the grid.

0.5 is the largest signal frequency magnitude that can be represented in every direction of the 2D grid.
We want the MTF to be independent of direction, so the higher frequencies are not really "physical", that's why it's limited to 0.5.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Adding this explanation as a comment would be useful 👍


def estimate_cut_off_frequency(mtf: sc.DataArray) -> sc.Variable:
'''Estimates the cut off frequency of
the modulation transfer function (mtf).
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can you add a link to a reference with the method to estimate the cut-off frequency in the docstring?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't have a reference for it. It's just something I came up with. Didn't find anything after making a quick search for it, and figured this is good enough. It's not like we can exactly determine the cutoff frequency in a noisy experiment anyway.

maxiters = 100
for _ in range(maxiters):
p = np.polyfit(_freq[m], _mtf[m], 1, w=w[m])
if abs(-p[1] / p[0] - fc) < 1e-4:
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should the threshold parameter be an input argument that can be tuned by the user?
If not, why was the number 1e-4 chosen?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't think it makes sense for the user to tune that parameter. The parameter represents the difference between the previous iteration and the next, so reducing it means we don't stop the iteration before the difference is even smaller than 1e-4. But the method does not produce a result with 4 significant digits anyway, so reducing it does not reduce the error.

You could increase the threshold, to reduce the number of iterations and reduce runtime, but it's not like the algorithm takes any time as it is, didn't measure the runtime but it's in ms. So that doesn't make much sense from the users perspective.

If we get requests for this in the future we can always add it later. I think it's better to start with a simpler interface and add complexity as requested, than it is to start with a complex interface.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I agree that a simple interface is better. Can you just add a small comment as to why this number works well? (basically the end of the first paragraph in your response?)

_freq = np.concat([[0.0], mtf.coords['frequency'].values])
_mtf = np.concat([[1.0], mtf.values])
# The line should go through (0, 1), so give it a big weight.
w = np.concat([[10 * len(mtf)], np.ones(len(mtf))])
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

How was the number 10 chosen?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The weight should be something that is significantly larger than the weight of all other points together.
But not so large that we run into numerical issues.
I picked 10 x total_weight and it seems to work well, I didn't try other values.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Add this explanation as a comment would still be useful. It would at least make it clear to someone reading this that the number 10 may not be a hard requirement, but a value someone can experiment with.

Saying that a value was obtained from trial and error is completely legitimate.

Comment on lines +25 to +26
"siemens-star-measured.h5": "md5:8e333d36c7c102f474b2b66cb785f5e8",
"siemens-star-openbeam.h5": "md5:ee429b2c247aeaafb0ef3ca4171f2e6a",
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think we have a similar issue with some of the other files in here, so it's not specific to these changes: we should probably add descriptions on how these images were created (link to a script we used if we have that, or link to where we got the image from).

@jokasimr jokasimr requested a review from nvaytet October 6, 2025 12:00
@jokasimr jokasimr merged commit 1a164fe into main Oct 6, 2025
4 checks passed
@jokasimr jokasimr deleted the spat-res branch October 6, 2025 14:33
@github-project-automation github-project-automation bot moved this from Selected to Done in Development Board Oct 6, 2025
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

Status: Done

Development

Successfully merging this pull request may close these issues.

[Requirement] SiemensStar spatial resolution measurement

4 participants