Skip to content

OpenICE IR removal method - #724

Merged
marcinz606 merged 2 commits into
mainfrom
feat/openice
Aug 1, 2026
Merged

OpenICE IR removal method#724
marcinz606 merged 2 commits into
mainfrom
feat/openice

Conversation

@marcinz606

Copy link
Copy Markdown
Owner

Ports openICE, a reverse-engineering of Nikon Scan's Digital ICE verified byte-exact against the original engine, as a second IR reconstruction alongside the existing one. Both are GPL-3.0, so no licensing friction.

Selected by a new Method combo at the top of the IR Removal panel. Default is unchanged — this ships as a bake-off, not a replacement.

Why

Where the current fill averages fine structure away and pastes borrowed grain over it, ICE rebuilds the picture: working in log density, each detail band is added back only where it beats the infrared's own contrast at that same scale, so the IR channel itself decides what is photograph and what is dust.

What had to change

The algorithm ports cleanly; its constants do not.

The level constants are absolute and tuned to a Coolscan under Nikon Scan. On the LS-9000 frame in samples/dust/, every pixel clears ICE's clear-film gate (τ=8847) — so its crosstalk fit would treat dust as film — and its ramp saturates at a 15% IR loss the deepest dust here (−11%) never reaches. Clear-film level, ramp and dust floor are now measured per frame from the gate's own distribution. openICE's docs concede this for flatbeds; it also bites a genuine Coolscan driven by different software.

Added a confidence write ramp, which ICE has no equivalent of. It rebuilds any pixel under full confidence and floors the result at the original, which half-wave-rectifies IR noise into one-sided brightening. That mottled 25% of a synthetic clean frame. With the ramp, clean film comes out bit-identical.

Dropped the LCG dither (the finest band already restores real grain, and it was the only serial dependency in an otherwise per-pixel algorithm) and added a per-component route cap — the film rebate sits above the dead floor on some scanners and arrived as one component covering 1.2% of a flatbed frame, blowing the inpaint budget and taking the real dust with it.

Isolation

openice.py imports nothing from logic.py; the only join is one branch in _ir_bake plus the config field and its sidebar row. Constants that look duplicated are duplicated deliberately. Whichever method loses can be deleted as one file and one branch — noted in CLAUDE.md so it survives contact with future refactors.

Verification

  • Against the C reference (built from src_C, 64-bit): confidences and gate pyramid match to float32 noise across all four scales; give-up map agrees on 99.95%. This caught a real omission — I had dropped ICE's θ gate bias as negligible and it showed up as a clean 1.0000 offset, so it went back in.
  • Both samples/dust/ scans: OpenICE leaves clean film 100.00% untouched (current path: 99.83–99.98%) with a 4.7× smaller mean change, for comparable dust removal.
  • End-to-end through the real GUI, headless: loads, detects IR, both methods render and differ, combo syncs.
  • 21 new tests in tests/test_ir_openice.py; the existing 60-test IR suite untouched. make all green (3010 passed).

Note for a follow-up

The existing NegPy path logs routed defects cover 2.2% of the frame — inpaint skipped on the flatbed sample for the same rebate reason fixed here. Left alone to keep the methods isolated, but it is a real bug in the default path.

Ports openICE, a byte-exact reverse-engineering of Nikon Scan's Digital ICE,
as a second IR reconstruction alongside the existing chain. Selected by a
Method combo in the IR Removal panel; default is unchanged.

Where the current fill averages fine structure away, ICE rebuilds it: in log
density, each detail band is added back only where it beats the infrared's own
contrast at that scale, so the IR decides what is picture and what is dust.

Three departures from the original, all forced by real scans:

- Its level constants are absolute and tuned to a Coolscan under Nikon Scan.
  They do not transfer — on the LS-9000 sample every pixel clears its
  clear-film gate and no dust reaches its ramp. Clear-film level, ramp and
  dust floor are measured per frame from the gate's own distribution.
- Added a confidence write ramp. ICE rebuilds any pixel under full confidence
  and floors at the original, which half-wave-rectifies IR noise into one-sided
  brightening; that mottled 25% of a clean frame. Clean film now comes out
  bit-identical.
- Dropped the LCG dither (the finest band already restores real grain, and it
  is the only serial dependency), and added a per-component route cap so the
  film rebate cannot eat the inpaint budget.

The two methods share no code: openice.py imports nothing from logic.py and the
only join is one branch in _ir_bake, so whichever loses can be deleted whole.

Verified against the openICE C reference built from src_C: confidences and gate
pyramid match to float32 noise, give-up map agrees on 99.95%. On both
samples/dust/ scans OpenICE leaves clean film 100.00% untouched against the
current path's 99.83–99.98%, for comparable dust removal.
@thetalkingdrum

Copy link
Copy Markdown
Contributor

Wow!

Two constants in the port had no counterpart in the C, and both cost small
dust its repair.

The write ramp scaled the output by confidence. ICE writes max(L3, acc) at
full strength for every pixel under full confidence; the ramp looked like
cheap insurance against mottling, but dust only reaches the weight floor once
it is deep, so a shallow speck lost most of its lift — 81% at a 15-25% dip.
The sigma-scaled margin already gates the noise the ramp was meant to stop:
removing it leaves clean-film activity at 1e-4, well under the repair itself.

The give-up dust floor was anchored to noise sigma, about ten times shallower
than ICE's D(0.065*M). That fired the trigger on ordinary deep dust and routed
it away from the reconstruction that should have repaired it. Carried over as
the transmittance fraction ICE's constant encodes — a defect passing under
6.5% of clear film — measured against this frame's clear-film reference, so it
transfers between scanners the way the absolute level does not.

Small-speck coverage rises across the sample scans (a 15-25% IR dip goes from
26-31% repaired to 34-45%); the film-border transition is correspondingly more
strongly rewritten, since the trigger no longer shields it.

Also verified the rest of the port against src_C and found no further drift:
pyramid supports and their 1/69, 1/21, 1/16 normalisation, the crosstalk
regression and reference install, gate bias, band coefficients and stage gains,
the give-up probe geometry, and the zero-pad/edge-replicate split.
@marcinz606
marcinz606 merged commit 69befb9 into main Aug 1, 2026
1 check passed
@marcinz606
marcinz606 deleted the feat/openice branch August 1, 2026 15:53
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.

2 participants