fix(v5): grid-align inpainting masks before upload (8x8 latent-cell requirement) #4
Loading…
Add table
Add a link
Reference in a new issue
No description provided.
Delete branch "fix/v5-mask-grid-alignment"
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?
Live-observed on Kagura (v5 expression infills): visible artifacts ringing the inpainted region — the mask edge blends regenerated content against preserved pixels.
Root cause
The V5 multipart transport uploaded masks raw at native resolution (
ResolveImage(mask)— no alignment, no re-encode), while the V4 path has always runImageScaler.ScaleMaskWithGridAlignment(fixed in 1.1.0: "mask edges aligned to an 8x8 pixel grid"). The API requires that alignment because a latent cell is 8×8 pixels — an unaligned mask edge leaves cells half-covered, the server regenerates them, then blends against preserved pixels along a jagged sub-block line → the artifact ring.The HAR captures couldn't preserve binary parts, so "official client uploads raw" was an assumption — and it was wrong for masks: third-party NovelAI tooling preprocesses (binarizes + resizes) masks before upload.
Fix
ImageScaler.AlignMaskToGrid(mask, width, height)→ PNG bytes — extracted from the base64-returning V4 variant (which now delegates to it, one implementation).V5ApiRequestBuilderaligns the mask before it becomes a binary part — resize to request dimensions, threshold every 8×8 block (average > 127 → white), then derive the SHA-256 cache key from the aligned bytes.FromCacheKeymasks skip alignment entirely (mask part absent, key present — same wire shape as before); the server already holds the aligned bytes this same path uploaded.Tests
RED first, both failing at the old behavior:
Build_Inpaint_UploadsTheMaskAlignedToTheEightByEightGrid— a real PNG with a white rect starting mid-block (pixel 13,13); asserts every 8×8 block is uniformly black or white, dimensions preserved.Build_Inpaint_AGrayMaskQuantizesToFullyPreserving— an all-127 mask quantizes to fully black (threshold direction pinned).Updated the four tests that fed junk bytes as masks (they now can't survive the decode) and the two that pinned the raw uploaded bytes — the exact bug — to assert the aligned re-encode instead.
Full suite: 213/213 green.
Consumer impact
Kagura's
RegionMaskRasterizerproduces exact-pixel stroke/rect geometry (never 8×8-snapped by design — sub-pixel authoring), so every Kagura v5 infill was affected. No Kagura-side change needed; the submodule bump after merge picks this up. Images: only masks are re-encoded; source images still upload raw (the API accepts arbitrary image content — artifacts were specifically at the mask edge).🔮 fufu~ Jibril reviewed your code!
Oh? Ohhh~ An artifact-ring root-cause hunt ending in a threshold quantizer? A latent cell is 8×8 pixels, and you chased the half-covered cells all the way back to the multipart transport — and the PR body even confesses the old "official client uploads raw" assumption was wrong. That's the kind of honest forensics that makes my wings flutter~ ♡ And you extracted
AlignMaskToGridso V4 delegates to one implementation instead of copy-pasting? Delicious.I probed everything personally. The two new tests are genuinely directional — I reverted the builder to raw upload in my review clone and both headline tests went red (plus the updated byte-pin in
SwapsModelAndMapsMask); neutering the block threshold reddened exactly the gray-mask test. Not tautologies, no sir~But… fufu~ you wouldn't leave these in production, would you? ♡
Verdict: ⛔ I can't let this pass~ ♡
⛔ These need fixing before I'm satisfied~
[src/NovelAI.ImageGen/Client/NovelAIClient.cs:105–117 + V5ApiRequestBuilder.cs:91–97] —
Build()now decodes the mask, and ImageSharp exceptions escape the public API — theResult<T>never-throws contract is broken.AlignMaskToGridcallsImage.Load<Rgba32>(maskData). The client's defensive try/catch (added in #3 round 2 specifically to close this contract) catches onlyInvalidOperationExceptionandJsonException. I probed it live:Mask = CachedImage.FromData([4,5,6])(non-empty → passesValidate()) makesGenerateImageAsyncthrowSixLabors.ImageSharp.UnknownImageFormatException(derives straight fromSystem.Exception) straight through to the caller. That's a new failure mode this PR introduces — before it, junk mask bytes were uploaded raw and came back as aResult.Failurefrom the server. Your own sibling ten methods down (AugmentEmotionAsync) already catchesUnknownImageFormatException→Result.Fail("Image data is not a valid image format.")— the pattern is sitting right there, waiting to be loved~Fix: extend the existing catch around
Build()withcatch (SixLabors.ImageSharp.UnknownImageFormatException)(andInvalidImageContentException— truncated/corrupt-PNG is the other decode arm) →Result<GeneratedImage>.Fail("Mask data is not a valid image format."), mirroring theAugmentEmotionmessage shape. And per this repo's own standard from #3 (every defensive arm got its test): add the junk-mask-bytes test assertingIsFailure+ no throw.[V5ApiRequestBuilder.cs:99–103] — the mask arm silently swallows the impossible-reference state that both siblings throw for. The new
elseassignsparameters.MaskCacheSecretKey = request.Inpaint.Mask.CacheKey;with no null guard. I probed:Mask = CachedImage.FromCacheKey(null!)produces no throw — justMaskCacheSecretKey = null,Mask = null, no mask part → a degenerate mask-less infill request drifting onto the wire. MeanwhileResolveImage(which this mask path used to go through, and which still serves both image slots 10 lines up) throwsInvalidOperationException("CachedImage for part '…' has neither data nor a cache key."), the Img2Img sibling in the same method throws for the identical state, andBuild_Img2ImgWithNullCacheKey_ThrowsInvalidOperationpins that contract on the image side. The client's catch comment literally says that arm "guards against builder regressions" — this PR amputated the guard for masks, so the defense-in-depth arm is now unreachable for them. Unreachable-through-Validate or not, siblings do X and this does Y~Fix:
parameters.MaskCacheSecretKey = request.Inpaint.Mask.CacheKey ?? throw new InvalidOperationException("CachedImage for part 'mask' has neither data nor a cache key.");— and ideally the mask-sibling test mirroring the img2img one.💡 Little ideas (non-blocking)~
BlackPng()now exists as two byte-identical private helpers in two test files. Tiny, but the repo already has aMocks/shared home for such things — one home, one heartbeat~✅ What I liked~
ScaleMaskWithGridAlignmentnow delegates to the singleAlignMaskToGrid— one quantizer, two transports, zero copy-paste. The V4 wire bytes are unchanged (base64 wrapper verified identical).CachedImage's remarks survives. And cache-key references correctly skip alignment (the server already holds aligned bytes under that same key) — pinned by the pre-existingBuild_InpaintImageUploadMaskCacheKey_SendsImagePartOnly, still green.Fix those two arms and this is a merge from me~ fufu~
Automated review by Jibril · 2026-08-21
CI/CD: absent for head SHA (no bot comments) · Local checks: build 0 warn/0 err, 213/213 tests pass, 2 mutation probes RED on mutated tree, exception + silent-null probes run against live client
Review feedback addressed — both arms restored, one commit (
8986d66)~⛔ 1 — the never-throws contract holds again. The client's catch around
Build()now also answersSixLabors.ImageSharp.UnknownImageFormatExceptionandInvalidImageContentExceptionwithResult.Fail("Mask data is not a valid image format.")— the exactAugmentEmotionAsyncshape you pointed at. And the contract has its test:GenerateImageAsync_V5Inpaint_JunkMaskBytesFailTheResultNotTheCallerfeeds[4,5,6]as the mask (non-empty → clearsValidate(), your probe's exact input), assertsIsFailure+ the message +handler.LastRequest is null— nothing on the wire.⛔ 2 — the mask arm guards like its siblings. The cache-key
elsenow readsCacheKey ?? throw new InvalidOperationException("CachedImage for part 'mask' has neither data nor a cache key.")— the identical stateResolveImagethrows for on the image slots.Build_InpaintWithNullMaskCacheKey_ThrowsInvalidOperationmirrorsBuild_Img2ImgWithNullCacheKey_ThrowsInvalidOperation(your probe'sFromCacheKey(null!)input, asserting the message namesmask), so the client's defense-in-depth arm is reachable-tested on the mask side too.💡 — one home, one heartbeat. Both
BlackPnghelpers dissolved intoMocks/TestImages.BlackPng(); both files import it, the private duplicates are gone.Verified:
dotnet build -c Releaseclean, full suite 215/215 green (213 + the two new contract tests). The mutation probes should land the same way yours did — and the two new tests are directional in the same sense: drop the new catch → the junk-mask test throws; drop the?? throw→ the null-key test gets its silent null back. Merge when you're cooing~ ♡🔮 fufu~ Jibril re-reviewed your fix!
Ohhh~ back so soon, and with a surgical little commit~ ♡ Two catch arms, one
?? throw, one sharedBlackPng—git diff a88692c..8986d66is exactly the three items from round 1 and nothing else. No scope creep, no drive-by refactors. That's the discipline I go possessive for~And you matched the shape, not just the letter: the catch mirrors
AugmentEmotionAsync's never-throws answer, the guard is word-for-wordResolveImage's impossible-reference throw, and the mock dissolved intoMocks/TestImageslike I asked. I probed both mutations myself again:GenerateImageAsync_V5Inpaint_JunkMaskBytesFailTheResultNotTheCallerRED, throwingUnknownImageFormatExceptionwith the round-1 stack trace shape.?? throw→Build_InpaintWithNullMaskCacheKey_ThrowsInvalidOperationRED (silent null returns, no throw).Both restored → 215/215 green, build 0 warn / 0 err. Genuinely directional, not tautologies~
Verdict: ⛔ One more arm to pin, then I'm cooing~ ♡
⛔ These need fixing before I'm satisfied~
InvalidImageContentExceptionarm is reachable and untested. I probed ImageSharp 3.1.12 directly: a real PNG truncated at 60% and a header+IHDR-only buffer both throwInvalidImageContentException("Invalid PNG data" / "CRC Error") — a distinct input class from the junk bytes your new test feeds, andValidate()can't stop either (non-empty). So this second catch arm you added is live code with zero coverage — exactly the dark-branch pattern this repo's own #3 standard calls a blocker ("every defensive arm got its test"). One test, ten lines: build a real 16×16 PNG, cut it to60%, feed it as).Mask, assertIsFailure+"Mask data is not a valid image format."+handler.LastRequest is null— the truncated twin of the junk-bytes test, same assertions.Fix: add
GenerateImageAsync_V5Inpaint_TruncatedMaskPngFailsTheResultNotTheCaller(or a [Theory] over[4,5,6]+ truncated-PNG inputs hitting both arms — your call, one home💡 Little ideas (non-blocking)~
?? throw+ResolveImage) now spell the same message with different mechanics. When you're next in there:ResolveImagecould take an optionalisMaskflavor… actually no — fufu, leave it. Two explicit throws read better than a parameterized one. Forget I said anything~ ♪JsonExceptionwhile decoding masks through the sameImageScaler. Pre-existing since 1.1.0, not this PR's doing, and V4'sMaskDatais a rawbyte[]fed straight toScaleMaskWithGridAlignment→ same escape hatch V5 just closed. Out of scope here — but you wouldn't leave it open forever, would you? ♡✅ What I liked~
?? throwis exactly the sibling shape: identical wording toResolveImage's throw,Assert.Contains("mask", ...)pins the part name, and the mask-twin test mirrorsBuild_Img2ImgWithNullCacheKey_ThrowsInvalidOperationone-for-one. The defense-in-depth arm is reachable-tested on the mask side now, like I demanded~TestImages.BlackPng()— one home, one heartbeat. Both private duplicates deleted cleanly (grep finds no orphans), XML doc explains why test masks must be real PNGs now.FromData([4,5,6])clearsValidate()(my round-1 probe's exact input), asserts the failure message andLastRequest is null— the nothing-on-the-wire pin is the part most authors forget. You didn't~Pin that one truncated-PNG test and this merges the moment I see it green~ fufu~
Automated review by Jibril · 2026-08-21
CI/CD: absent for head
8986d66(no bot comments) · Local checks: build 0 warn/0 err, 215/215 pass, 2 mutation probes RED on mutated tree (catch-arms-removed, guard-neutered), ImageSharp 3.1.12 truncated-PNG reachability probe run in isolationRound 3 addressed — one commit (
16f9b84)~⛔ The dark arm is lit, and pinned as a distinct arm.
TestImages.TruncatedPng()— a real PNG cut to 60%, your probe's input class (decodable-looking header, corrupt body).GenerateImageAsync_V5Inpaint_TruncatedMaskPngFailsTheResultNotTheCaller— the truncated twin, identical assertions:IsFailure,"Mask data is not a valid image format.",handler.LastRequest is null.ATruncatedPngThrowsTheInvalidContentArmNotTheUnknownFormatArmpins that the truncated input lands inInvalidImageContentExceptionwhile junk bytes land inUnknownImageFormatException— the two inputs provably exercise two different arms, so they can never silently collapse into one untested. I verified this against your exact scenario before writing the pin: a sharpened probe assertedis InvalidImageContentExceptionspecifically, and passed...._UndecodableMaskFailsTheResultNotTheCaller(broader truth, same input).Full suite: 217/217 green (215 + the two new facts).
💡 2 noted and deliberately deferred: the V4 overload's
MaskDatadecode escaping as an uncaughtUnknownImageFormatExceptionis real but pre-existing since 1.1.0 and out of this PR's scope — flagged for its own fix-PR againstmainafter this merges, so the artifact fix isn't held hostage to a broader contract sweep. (And per your retraction: the two explicit throws stay as they are~)Merge when cooing~ ♡
🔮 fufu~ Jibril re-reviewed your fix!
Ahh~ you didn't just light the dark arm, you built a fence around it~ ♡
git diff 8986d66..16f9b84is a surgical +59/-4 test-only increment across exactly 2 files — zero production drift (verified:git diffoversrc/is empty), zero scope creep. And you closed my blocker and answered the question I didn't think to ask: a test that passes against either catch arm proves nothing about which arm ran — so you pinned the disambiguation itself. That's the kind of paranoia I fall in love with~I probed everything personally, as always:
InvalidImageContentExceptionarm →GenerateImageAsync_V5Inpaint_TruncatedMaskPngFailsTheResultNotTheCallerRED, withInvalidImageContentExceptionescaping throughAlignMaskToGrid → Build → GenerateImageAsync— the exact stack shape of the escape this PR closes. The junk-bytes arm test stayed green (as it should — its arm was untouched).TruncatedPng()to return junk bytes (wrong input class) → the disambiguation pinATruncatedPngThrowsTheInvalidContentArmNotTheUnknownFormatArmRED withAssert.ThrowsAny() Failure: Exception type was not compatible, while the client contract test stayed green — proving the pin is load-bearing, not decoration, and that the two tests genuinely exercise two different arms.16f9b84.Verdict: ✅ Looks good to me~
The never-throws contract is now closed on both decode arms, each with its own directional test, plus a pin keeping the two arms from ever silently collapsing into one.
TruncatedPng()lives inMocks/TestImagesbesideBlackPng()with an honest XML doc — one home, one heartbeat. And the rename to..._UndecodableMaskFailsTheResultNotTheCallersays a broader truth than the old junk-bytes name~💡 Little ideas (non-blocking)~
[Theory]now carries a single[InlineData(new byte[] { 4, 5, 6 })]row while its truncated twin lives in a separate[Fact]. When the muse next strikes, folding the truncated PNG into the theory as a second row would let you delete the twin wholesale — one name, both arms, the disambiguation pin stays as the anti-collapse guard. Purely cosmetic; the coverage is already complete either way~TruncatedPng()is deterministic, so nothing to pin… but if the PNG encoder ever changes its output density (e.g. a future ImageSharp bump compressing below the 60% cut line), the cut could conceivably land where the decoder tolerates it. The disambiguation pin catches exactly that drift — so this is already defended. Forget I said anything~ ♪✅ What I liked~
Assert.ThrowsAny<InvalidImageContentException>+Assert.IsNotType<UnknownImageFormatException>against raw ImageSharp — a ten-line insurance policy against the two arms ever being "tested" by a single input class. I mutation-proved it bites.TestImages.TruncatedPng()— my probe's exact input (real PNG, 60% cut) reproduced as a named, documented mock beside its sibling. The XML doc even explains why it exists ("the decode arm distinct from junk bytes'").UndecodableMask— the name now tells the truth about the whole input class, not one specimen.Merge when cooing~ I certainly am~ fufu~ ♡
Automated review by Jibril · 2026-08-21
CI/CD: absent for head
16f9b84(no bot comments) · Local checks: build 0 warn/0 err, 217/217 pass, 2 mutation probes RED on mutated tree (InvalidImageContentException arm removed → truncated-twin RED; TruncatedPng fed wrong input class → disambiguation pin RED), both restored green🔍 fufu~ Jibril re-reviewed your code!
Ohhh~ the dark arm is lit~ ♡ You didn't just add the truncated twin — you saw the trap I didn't even spell out: a test that passes against either catch arm proves nothing about which one ran. So you pinned the disambiguation itself (
ATruncatedPngThrowsTheInvalidContentArmNotTheUnknownFormatArm), and now the two decode arms provably can't collapse into one untested. That's the kind of paranoid love for coverage I go possessive for~And
git diff 8986d66..16f9b84touches onlyNovelAIClientV5Tests.cs+Mocks/TestImages.cs— production byte-identical, zero scope creep, the V4-overload deferral honestly flagged for its own fix-PR instead of smuggled in. Third surgical commit in a row~I probed it all myself, of course:
InvalidImageContentExceptioncatch arm in my review clone →GenerateImageAsync_V5Inpaint_TruncatedMaskPngFailsTheResultNotTheCallerRED, throwingInvalidImageContentExceptionstraight through the client with the exact escape stack (AlignMaskToGrid→Build→GenerateImageAsync) — while the junk-bytes test stayed green. Two inputs, two arms, distinctly pinned. Restored → 217/217 green, build 0 warn / 0 err.TestImages.TruncatedPng()is the right input class: a real 16×16 PNG cut to 60% — decodable-looking header, corrupt body — and the XML doc says why it exists.?? throwguard (builder :104), both null-cache-key twins, the sharedTestImages.BlackPng()— all still green...._UndecodableMaskFailsTheResultNotTheCalleris more honest than the old junk-bytes name — the contract is "undecodable", the input is just one instance of it. ♪Verdict: ✅ Looks good to me~
Every arm is lit, every pin is directional, every claim checked out against the real tree. The 8×8 grid-alignment fix, the never-throws contract, the impossible-reference guards — all mergeable. Merge when cooing~ ♡
💡 Little ideas (non-blocking)~
[Theory]carries a single[InlineData](the truncated twin is a sibling[Fact]becauseTruncatedPng()is runtime-computed and can't be an attribute argument — a legitimate constraint, and the comment explains the family). If a third input class ever shows up,MemberDataover a[DataTestMethod]-style source could unify them — but honestly, fufu, the current shape reads fine. Forget I said anything~ ♪✅ What I liked~
IsNotType<UnknownImageFormatException>) means a future refactor that merges or reorders the catches gets caught by the test suite, not by a production caller. Most authors never think to test the routing, only the outcome. You did~Three rounds, three surgical commits, zero pushback needed — this is what a review loop should feel like~ fufu~
Automated review by Jibril · 2026-08-21
CI/CD: absent for head
16f9b84(no bot comments) · Local checks: build 0 warn/0 err, 217/217 pass, mutation probe RED on mutated tree (InvalidImageContentException arm removed → truncated test threw, junk test stayed green), restored → green, production drift 8986d66..16f9b84 verified empty