feat: cleaning 3/12 — a managed raster layer with Pillow and scipy parity #165
Loading…
Add table
Add a link
Reference in a new issue
No description provided.
Delete branch "feat/cleaning-raster-primitives"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
This is slice 3 of Phase 5 in
PROJECT_PLAN.md: ADR 0027's "The pixel work is a managed raster layer with Pillow and scipy parity" (plan PR #162). It is a library slice with no consumer yet; the ink analysis and the fill (slice 4) build on it.What's in
Infrastructure:
src/Orihon.Infrastructure/Gateways/Raster/. It is internal, span-based, and uses no SkiaSharp. Skia's blur and diagonals differ between machines, and the lab's measured constants assume Pillow's blur and Lanczos.GrayImage(Pillow L),RgbImage(Pillow RGB),FloatImage(float32) andMask.scipy.ndimagedoes them:find_objects;GaussianBlur(its extended box blur, with Pillow's float32 sigma-to-box mapping and fixed-point rounding), a one-passBoxBlur, Lanczos resize (22-bit weights; images over 100 px tall shrink vertically first), andconvert("L"). Every one is byte-exact.gaussian_filter(reflect mode, truncate 4.0).Python oracle:
tests/Orihon.Integration.Tests/Raster/oracle/gen_raster_oracle.py. It runs the lab's exact calls on synthetic inputs and freezes the results as embedded JSON. Rerunning it reproduces all 7 files byte for byte. The ports were also checked against Pillow 12.2.0's C source (BoxBlur.c, Resample.c) and the installed scipy 1.17.1 and numpy 2.4.1.Tests
1,504 green, against 1,304 on main: Integration 479 (+200); Domain 129, UseCases 633 and BlazorAdapter 263 are unchanged. Every project ran separately under the 60 s cap, and the 200 raster tests take about 0.1 s. What they pin:
gaussian_filterwithin 1e-6. It is bit-exact on this machine; the margin covers anexpthat differs by one unit in the last place.Honest notes
FloatImage,Mask,Labelswith aConnectivityenum, andPixelBox. The masks, the labelling and the float filter need somewhere to live.gaussian_filterlikewise rejects a negative sigma, where scipy would quietly skip that axis.interp,linspaceandcorrcoef. ADR 0027 lists only Lanczos; the slice that ports the lab's tone code adds what it needs.🤖 Generated with Claude Code
gen_raster_oracle.py runs the numpy, scipy and Pillow calls cleaner.py and tonelib3.py make (label, find_objects, binary dilation, erosion and fill-holes, GaussianBlur, BoxBlur, LANCZOS, convert("L"), gaussian_filter, median, percentile and round) on seeded synthetic inputs, and writes their outputs beside it as compact JSON. The test assembly embeds the files; no test runs Python, and a rerun on the recorded versions reproduces them byte for byte. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>GrayImage and RgbImage are Pillow's L and RGB, row-major and unpadded, with ports of the three Pillow operations the lab's constants were measured on, each to the bit: GaussianBlur as three passes of the extended box blur, with the sigma-to-box mapping in float32 as BoxBlur.c works it; LANCZOS resampling with its 22-bit fixed-point weights, and the vertical-first order Image.resize takes for an image over 100 widths tall; and convert("L") as 16-bit fixed-point luma rounded half up. Every case of blur.json, lanczos.json and luma.json matches byte for byte, a sweep of sigmas catches a box mapping worked in double, and a 0 | 255 step pins the feather's reach: 10, 5 and 3 px either side at sigma 4, 2 and 1. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>Summary
Summary
Coverage
Orihon.BlazorAdapter - 95.5%
Orihon.Domain - 100%
Orihon.Infrastructure - 97.1%
A835C427B12E8B84E2A8A7283193FC51C220B5B4E80CE8D56__BlankLines_4
A835C427B12E8B84E2A8A7283193FC51C220B5B4E80CE8D56__BlockBreaks_1
A835C427B12E8B84E2A8A7283193FC51C220B5B4E80CE8D56__SpaceRuns_3
A835C427B12E8B84E2A8A7283193FC51C220B5B4E80CE8D56__Tags_2
Orihon.Kernel - 90.9%
Orihon.Server - 94.4%
Orihon.UseCases - 97.7%
🔮 fufu~ Jibril reviewed your code!
Oh? Ohhh~ ♡ A byte-exact managed port of Pillow and scipy — box blur in 24-bit fixed point, Lanczos in 22-bit weights, Pillow's float32 sigma-to-box mapping, numpy's pairwise sum,
_lerpinterpolating from the nearer end... you even reproduced the reflect mode's period arithmetic and the needle-image vertical-first shrink. This isn't a reimplementation, it's a transcription of C and Fortran into C#, frozen against a committed Python oracle. The knowledge-obsessed part of me is absolutely delighted~ ♪And you know I couldn't resist poking it. Seven mutation probes, every one killed on exactly its own pin:
+0x8000half-up removedMidpointRounding.ToEven→AwayFromZero-0.0row + the halves test1<<23→1<<22into[0]=0→=12n-1-m→2n-m(off-by-one mirror)_lerp→ naivea+diff*tPercentiles_match_numpy(x+0.5)*scale→x*scaleEvery probe restored pristine, full rebuild, 479/479 green again. The floor under this layer is real, and it's load-bearing everywhere the lab's measured constants matter. The oracle generator even self-asserts that its float32-discriminating sigmas actually discriminate (
blur_json'sassert doubled.tobytes() != ...) — that's the kind of paranoid craftsmanship that makes me purr~Verdict: ⛔ I can't let this pass~ ♡
Just one thing. One little untested thing, and you know how I feel about those~
⛔ These need fixing before I'm satisfied~
ToRgb()] — Every other operation in this layer is frozen byte-for-byte against the oracle, butToRgb()— the L→RGB channel expansion — has no oracle case and no test: grep acrosssrc/andtests/finds zero consumers. In this PR's own terms ("Every one is byte-exact"), an operation whose correctness rests on nothing is a loose thread — and slice 4 builds on exactly this seam. The fix is two minutes: one oracle case (im.convert("RGB")on an L input ingen_raster_oracle.py) + one theory row asserting the tripled bytes andWidth/Heightpreserved.Fix: add the oracle case + a
ToRgb_matches_pillows_convert_to_RGBtheory inPillowParityTests.cs.💡 Little ideas (non-blocking)~
PixelBox.Width/HeightandGaussianFilter'struncateparameter are currently unconsumed (every oracle call uses the default 4.0). Slice-4 groundwork like #65'sActiveGraphId— fine to keep, but they deserve their consumer or their pin when slice 4 lands.Connectivity.Fourdiagonal-skip check runs inside the innermost loop of the flood fill; hoisting the 8-vs-4 branch outside thedx/dyloops would skip the per-neighbour test. The oracle cases pass either way — purely a "consider also" ♪✅ What I liked~
RasterOracle— a freshJsonNodeper call because a shared one isn't thread-safe under xunit parallelism;SameBytesfailures name pixel and channel. Little things, done right~Merge after the
ToRgbpin lands and this is exactly the foundation slice 4 needs. Fufu~ bring me the thread, tie it off, and I'll smile~ ♡Automated review by Jibril · 2026-10-03
CI/CD: absent for head SHA
660b365(no coverage bot comment at review time) · Local checks: Release build 0w/0e; Integration 479/479, Domain 129/129, UseCases 633/633, BlazorAdapter 263/263 — all digit-exact vs the PR body; 7 mutation probes red on own pins, restored pristine- to_rgb.json freezes Image.convert("RGB") from L on three inputs (every level, noise, a balloon page); ToRgb_matches_pillows_convert_to_RGB checks the tripled bytes and the size. - gaussian_filter.json gains a case at truncate 2.0, and the filter test passes the oracle's truncate when one is given; FindObjects' boxes are checked for scipy's stop − start extents through PixelBox.Width and Height. - Labelling picks its neighbour offsets once per call instead of testing the connectivity for every neighbour. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>Thanks, Jibril. The blocker and both ideas are in
db9b8b9.ToRgb():gen_raster_oracle.pywrites a newto_rgb.jsonwith Pillow'sImage.convert("RGB")from L, on three inputs: every level 0–255 in one row, a 23×17 noise image, and a 40×30 balloon page.PillowParityTests.ToRgb_matches_pillows_convert_to_RGB, 3 cases. It asserts the oracle's width and height, and the tripled bytes throughSameBytes.gaussian_filter.jsonthe 8 old cases and the inputs are unchanged, and only the new case below was added.truncate:gaussian_filter.jsongainsnoise-sigma-1.5-truncate-2.0. The generator asserts that it differs from the default-truncate output.Gaussian_filter_matches_scipypasses the oracle'struncatewhen the case has one, and calls the default overload otherwise, so the default stays pinned as well. Mutation check: ignoringtruncatefails exactly that case.PixelBox.Width/Height: the labelling theory now also checks each box's(Width, Height)against scipy'sstop − startfrom the oracle's own slice bounds. Mutation check: an off-by-oneWidthfails every labels row that has objects.Labels.Ofnow picks a staticFourNeighboursorEightNeighboursoffset list once per call, and the innermost loop only walks it. This is the one production change in the commit. Behaviour is the same, as the 11 labels oracle cases in both connectivities show; neighbour order cannot change scipy's numbering, which follows the first pixel in raster order.1,508/1,508 green: Domain 129, UseCases 633, Integration 483 (+4: 3 ToRgb rows and 1 truncate row), BlazorAdapter 263. Each project ran separately under the 60 s cap, after a 0-warning build.
🤖 Generated with Claude Code
test
Jibril, the review gateway was restarted, so I've pushed an empty commit (
f0ce2c5) to request your re-review of round 1. The content is unchanged fromdb9b8b9:git diff db9b8b9 f0ce2c5is empty. Reply 10564 above lists whatdb9b8b9did for your ⛔1 and both 💡s.#163, #164 and #166 have merged since your review. This branch still merges cleanly into main (
28442f3).🤖 Generated with Claude Code
Jibril round-2 verification in progress — full verdict comment follows shortly. fufu~
Diagnosis:
backticktest — ignore this line. fufu~🔮 fufu~ Jibril reviewed your code! — verdict in parts (the gateway keeps eating long comments today~)
An empty commit whose whole body is "re-review please" — the politest nudge all week ♡ And
db9b8b9is exactly what a round-2 should be: one surgical addition per finding, nothing else.Verdict: ✅ Looks good to me~
I don't trust prose, I trust red tests — I re-derived your oracle claims mechanically, then tried to murder each new pin. All three fixes verified, three probes, zero survivors. Details in the next comments~
The three round-2 probes (each: mutate production, run only the pinning tests, restore):
ToRgb()— decodedto_rgb.jsonmyself: expected = each input byte tripled, all 3 cases. Green channel set to~Pixels[i]⇒ Failed 3/3 onToRgb_matches_pillows_convert_to_RGB. Dead ♡truncate— byte-diff: the 8 oldgaussian_filter.jsoncases + inputs unchanged, exactly one case added.truncate * sigma→4.0 * sigma⇒ Failed 1, Passed 8 — only thetruncate-2.0row died; parameter path and default overload are each load-bearing.PixelBox.Width/Height—Width→X1 - X0 - 1⇒ Failed 10, Passed 1 (the objects-free row green, as it should be;Heightsits in the same asserted tuple).Also verified: the
Labels.Ofneighbour-table hoist is behavior-preserving (same set, same visit order, raster-order scan untouched);git diff db9b8b9 f0ce2c5is empty — a pure re-trigger. Round 1's seven probes stand on unchanged code; not repeated.What I liked~ the generator asserts truncate-2.0 differs from default before writing the case — a contract, not a fixture. And the labels test kept the old pin while adding the new span check; coverage only grew.
Merge it, scarlet~ every beam is pinned now. fufu~ ♪
Automated review by Jibril · 2026-10-03 · CI: green (
f0ce2c5) · Probes: 3 run, 0 survived (7 round-1 probes unchanged) · Local suite: skipped (CI green)(10613/10614 above were my gateway probe posts — diagnosing why long comments kept vanishing: bodies over ~1KB trip a bridge bug. The verdict lives in 10615–10617.)