Skip to content

CTnoise.add: use MATLAB's default photon count and clamp non-positive readings - #781

Merged
AnderBiguri merged 1 commit into
CERN:masterfrom
Dev-next-gen:fix/ctnoise-default-photon-count
Sep 29, 2026
Merged

AnderBiguri merged 1 commit into
CERN:masterfrom
Dev-next-gen:fix/ctnoise-default-photon-count

Conversation

@Dev-next-gen

@Dev-next-gen Dev-next-gen commented Sep 15, 2026 •

Copy link
Copy Markdown
Contributor

Fixes #780

I was comparing Python/tigre/utilities/CTnoise.py with MATLAB/Utilities/addCTnoise.m and found that the Python default for Poisson is np.ceil(np.log2(np.max(np.abs(projections)))). That is the exponent of the next power of two, not a photon count. For projections that peak at 300 it gives 9 photons, so the "realistic" noise ends up as large as the signal. On top of that, the Gaussian term drives some readings to zero or below, and -np.log() turns those into NaN or inf. MATLAB avoids both problems: it defaults to I0 = 60000 (or max(proj)/5 when the projections go above that) and sets Im(Im<=0) = 1e-6 before taking the log.

This PR does the same in Python:

  • the default photon count is now 60000, or max(projections) / 5 when the projections exceed 60000, following MATLAB;
  • readings <= 0 after the noise is added are set to 1e-6 before the log, which also covers an explicitly low Poisson.

Nothing changes when Poisson is passed (every demo passes Poisson=1e5), except that a reading that used to become NaN or inf now comes out finite. The default value was never documented, and in practice it could not be used, so I don't think anyone depends on it. One thing to note: the help header of addCTnoise.m says the default is 1e5, while its code uses 60000. I followed the code; if you prefer 1e5, it is a one-line change.

I checked the change with two tests: that the default call returns finite projections with small noise, and that Poisson=5, Gaussian=[0, 2] returns finite values. They were in Python/tests/test_ctnoise_defaults.py in the first version of this PR and were removed on request, so the PR now only touches CTnoise.py. The machine I work on has no NVIDIA GPU, so I ran these tests with _RandomNumberGenerator replaced by a numpy model of GeneratePoissonAddGaussian (Poisson of the input plus normal * sigma + mu, float32). On master both tests fail: the default call gives about 400 NaN out of 32,768 values and finite values from about -250 to several thousand for a signal that peaks at 300. With the change, both pass, and the reproduction script from the issue prints True with a mean absolute error of about 1.4.

AI Usage

Found by a defect-hunting pipeline I build and run (Dev-next-gen), using Claude Code with Anthropic's Claude Opus 5.

  • Type of assistance: the AI tool found the defect, wrote the fix and the test used to check it (not part of the PR), and drafted this description.
  • Scope: Python/tigre/utilities/CTnoise.py (default photon count and clamp before the log).
  • How it was found: comparing the Python port with MATLAB/Utilities/addCTnoise.m, then confirming with a test that fails before the change and passes after it (RNG stubbed with a numpy model of the CUDA kernel, since no NVIDIA GPU was available).
  • Level of modification: 6 lines added and 1 removed in CTnoise.py.

Nothing this pipeline produces is submitted without human approval. The entire pull request — the code change, the tests and this description — was reviewed by a human before the pipeline was allowed to submit it.

(@Dev-next-gen)

@Dev-next-gen

Copy link
Copy Markdown
Contributor Author

I said in the description that I could not run these tests against a compiled TIGRE, only against a numpy model of GeneratePoissonAddGaussian. I have now run them on a real GPU, so here is the result with the actual curand RNG.

Setup: TIGRE built from this branch with nvcc 12.4, running on an NVIDIA GeForce RTX 3070 Laptop GPU (driver 610.88), Python 3.14, numpy 2.5.3. tigre.utilities.gpu.getGpuNames() reports the card, so the CUDA path is the one being exercised.

To isolate the change I swapped only tigre/utilities/CTnoise.py in the installed package between the two runs — it is pure Python, so nothing was recompiled and the CUDA extension is identical in both.

With CTnoise.py from master:

2 failed
  test_default_poisson_gives_finite_low_noise_projections FAILED
  test_low_photon_count_does_not_produce_nan_or_inf       FAILED
  assert np.isfinite(noisy).all() -> AssertionError

reproduction script from the issue:
  False nan
  385 NaN out of 32,768, finite values from -221.3 to 2558.0,
  mean absolute error 139.43, RuntimeWarning: invalid value encountered in log

With CTnoise.py from this branch:

2 passed

reproduction script from the issue:
  True 1.4446254
  0 NaN, finite values from -3.8 to 306.4, mean absolute error 1.44, no warning

The numbers I quoted in the issue from the stubbed RNG hold up on real hardware: about 400 NaN (385 here) and a mean absolute error around 140 (139.43 here).

One aside, unrelated to this PR: importing the package prints DeprecationWarning: numpy.core.arrayprint is deprecated from Python/tigre/utilities/filtering.py:3, where dtype_is_implied is imported but never used. Happy to send that as a separate one-line PR if it is useful.

Found by a defect-hunting pipeline I build and run (Dev-next-gen), using Claude Code with Anthropic's Claude Opus 5.

@AnderBiguri

Copy link
Copy Markdown
Member

Claude AI seems to keep adding unlimited tests to the test folder. Please uncommit these, and I will merge the PR.

… readings

With Poisson left to its default, the Python port set the photon count to
ceil(log2(max(|proj|))), which is 9 for projections peaking at 300. That
turns the requested realistic noise into noise as large as the signal, and
the Gaussian term then pushes many readings to zero or below, so -log()
returns NaN and inf. MATLAB's addCTnoise uses I0 = 60000 (max(proj)/5 if the
projections exceed it) and sets readings <= 0 to 1e-6 before the log; do the
same here.

Fixes CERN#780
@Dev-next-gen
Dev-next-gen force-pushed the fix/ctnoise-default-photon-count branch from 0a8ff95 to 2a4491e Compare September 29, 2026 11:34
@Dev-next-gen

Copy link
Copy Markdown
Contributor Author

Fair point on the tests, sorry about that. The test file is out and the PR now only touches Python/tigre/utilities/CTnoise.py (2a4491e).

(@Dev-next-gen)

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

CTnoise.add with default Poisson returns NaN and noise as large as the signal

2 participants