You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
Raises the accepted bitsPerCell maximum from 8 to 10. Values 9 and 10 already fit the existing 4-bit metadata field on versions 2, 3, and 4, so this does not add a stream-header flag and does not bump the metadata version. 16 still does not fit that nibble; 11–15 fit the field but stay outside the product cap.
Shards encoded at 9 or 10 bits will not decode on older readers that only accept 1–8. Existing 1–8 shards are unchanged: the same palette spacing guarantee (minimum channel step of at least 32) still applies, and golden fixtures were not regenerated. Images stay 8 bits per channel. At 10 bits the palette is 16×8×8, so the minimum channel step inside 8-bit RGB is 17; that tighter step is intentional. On a 2160 px canvas the existing calibration strip is about 2 px per block.
Encode and decode share one cell-stream cap (16384² bytes) and both reject a calibration strip that cannot give every palette colour at least one pixel, using the same rounded block edges the strip is drawn with. A 1 px grid at the maximum canvas still encodes at 8 bits and is rejected at 9 and 10. 700×700 at cell 3 and 10 bits is rejected because the strip is narrower than 1024 colours.
Classification confidence follows measured palette spacing. The floor is the largest squared distance at which a runner-up one step away cannot be a near-tie, so exact hits still skip the second-nearest scan. At 10 bits (step 17) that floor is 49: a cell encoded as red 0 and captured as red 9 is classified as red 17 at distance 64 and flagged. A sample that lands exactly on another palette colour still has distance 0 and is not flagged. The quality heatmap uses the same floor, clamped at the absolute-suspect distance (4000) so a sparse 1-bit palette does not paint a trusted sample as weak. When the floor and that cap are both 4000 the gradient does not divide by zero. A flagged near-tie paints amber (230, 140, 20), distinct from a unique exact hit and from an exact-tie red. Cells that are not flagged keep the distance gradient.
A measured uniform palette that is still a channel product is classified by that index, with the same lowest-index tie as a full scan. The runner-up uses the same index: another combination of tied channel levels, or one channel stepped to its next-worse level. A palette that is not a product uses a 16-wide colour grid for both the winner and the runner-up. That lookup matches the full scan's lowest-index tie on uniform cells and on interpolated rows, and falls back to the scan when the neighbourhood cannot prune. A slightly noisy measured strip (one channel nudged, plus a duplicate colour) agrees with the scan. Interpolated rows that stay a product take their spacing from the channel gaps. Rows that do not reuse one closest-pair workspace instead of allocating a spatial hash per row. Those floors are recorded during sampling, so diagnose does not measure them again, and the quality heatmap paints each row with its own floor. A top row at floor 49 can still show distance 36 as confident while a tighter bottom row does not.
A camera retry reuses the same diagnostics object. Each sampling pass clears both floors before it samples, then records only the floor that palette measured. A uniform retry therefore does not keep the interpolated pass's row floors, which the quality heatmap would otherwise prefer over the retry's uniform floor.
Interpolated rows measure their own spacing. Swapping two adjacent 10-bit reds between the strips leaves both endpoints at step 17, but the midpoint entries coincide; a red-10 sample is then a 1-vs-1 near-tie and is flagged. A sample of that coincident colour is distance 0 from both indices. That tie is flagged as well, and the other index is the Chase alternative. The margin stored for the tie is still 0, so sampling also records a per-cell ambiguity mark and the quality heatmap paints that cell red. The mark is kept when the image has no ECC. An exact hit on a unique colour in the same row stays confident and green. qrshard calibrate -r 20000 reports the 700–16384 range error instead of claiming every probe is too small. In-range probes that the strip cannot host are still skipped.
qrshard calibrate screen probes include 9 and 10 bits, and only the probes that resolution can host are written. Analysis still walks the full ladder, so a decoded 10-bit capture is recommended first. A 900 px canvas recommends -c 1 -b 9; 1280 px recommends -c 1 -b 10.
ReadCell and WriteCell use one 24-bit window for 1–10 bits. A 10-bit cell that starts on the last bit of a byte spans three bytes; a missing byte still reads as 0, and bits past the buffer are dropped. Widths 1–8 match the previous two-byte layout.
Separability is still closest × 4000 ≥ widest. The check uses an exact closest pair and the bounding-box diagonal, and falls back to the exact widest pair only when that bound cannot decide.
The all-zero separable-palette length check was already right: lengths 1<<9 and 1<<10 are valid Cartesian products, and 1<<11 and 1<<16 are declined. That assertion was left as it is.
maximum-canvas 9- and 10-bit layouts throw; the same 10-bit grid unpacks as null with no image allocated
a 700 px, cell-3, 10-bit layout is rejected on the strip, and the crafted strip unpacks as null
separability matches a brute-force closest/widest oracle, including a case that misses the bounding-box fast path
exhaustive RGB-cube agreement for densities 2–8; 9 and 10 checked on palette colours, channel midpoints, and a step-16 lattice
10-bit confidence floor is 49; red 4 is not suspect, red 9 is, and exact red 17 is not
the red-9 cell is recorded as a near-tie and the exact red-17 cell is not
uniform 10-bit lookup matches the scan, including the green-18 tie, and a broken palette still returns the scan's lowest index
product runner-up matches the scan, including the 10-bit red-9 near-tie, the green-18 tie, and a gained lattice
a slightly noisy measured strip matches the scan for nearest and runner-up, including the duplicate colour, the nudged colour, and one interpolated row
product-palette closest distance matches the pair search; a reused closest-pair workspace matches a fresh search, including after a duplicate pair
quality heatmap at floor 49 paints an unflagged margin 64 differently from an exact hit; floor 200 paints them the same
a flagged near-tie paints amber, distinct from that slight green, from an exact hit, and from the exact-tie red; an unflagged margin of 2000 stays on the gradient
a 1-bit floor above 4000 paints margin 1000 the same green as an exact hit; margin 5000 does not
per-row heatmap floors: margin 36 is fully green on floor 49 and not on floor 9; one minimum floor paints both rows the same
interpolated sampling records a wider floor on the bright row than on the dimmed row
an interpolated midpoint with swapped 10-bit reds flags a red-10 near-tie the endpoint floor of 49 would skip
that midpoint also flags a sample of the coincident colour, writes the other index as the Chase alternative, and leaves an exact hit on a unique colour in the same row unflagged
with ECC disabled, that coincident sample still stores margin 0 and an ambiguity mark; the quality heatmap paints it differently from the unique exact hit, and the same margin without the mark paints the same green
calibrate -r 20000 exits 1 with the 700–16384 range error and does not say the probes are too small
calibrate at 900 px recommends -c 1 -b 9 and does not emit a 10-bit probe; at 1280 px it recommends -c 1 -b 10
a diagnose camera retry that samples an interpolated palette and then a uniform one clears RowConfidentDist; the quality heatmap matches the uniform floor and not the stale row floor
cell windows for 1–10 bits match BitWriter at every alignment, including a 10-bit cell that spans three bytes; a short buffer drops the leftover bits and reads them as 0
Raise the product cap from 8 to 10 without a metadata-version bump.
9 and 10 already fit the existing 4-bit bitsPerCell field; 11 and 16
stay rejected, and 1–8 spacing is unchanged.
Co-authored-by: lfarrand <lfarrand@users.noreply.github.com>
An all-zero 512-color palette is a valid product once 9 bits is in
range. Lengths of 2^11 and 2^16 stay declined.
Co-authored-by: lfarrand <lfarrand@users.noreply.github.com>
This PR raises the supported color density from 8 to 10 bits per cell without changing the shard metadata format. It updates the core limit, user-facing guidance, and tests.
Changes:
Accept 9- and 10-bit palettes and document the new range.
Add layout, settings, palette, bit-stream, and round-trip test cases.
Document the tighter 10-bit color spacing and calibration-strip size.
The advertised 9–10-bit modes cannot be recommended by qrshard calibrate. CalibrationRunner.ScreenProbes stops at (1, 8), and Analyze reports and selects only entries from that list, so even a successfully decoded 10-bit capture is ignored. Add 9- and 10-bit screen probes where the resolution can represent all strip colors, include them in the recommendation order, and test the result at a sufficiently wide resolution (the existing 900 px fixture is too narrow for 10 bits).
The 10-bit palette has adjacent red levels only 17 apart, but GridSampler.RecordConfidence still trusts any sample with squared distance at most 200. A cell encoded as red 0 and captured as red 9 is classified as red 17 with distance 64; it is not marked suspect and has no second choice, so erasure and Chase recovery cannot help if errors exceed the normal correction budget. Lower the confidence floor for 10-bit layouts (accounting for measured palette spacing), keep the quality heatmap consistent, and add a noisy 10-bit capture test. The lower floor cannot detect errors that land exactly on another palette color.
Reject cell streams over 16384² bytes and calibration strips that cannot
paint every colour. Confidence and the quality heatmap follow measured
palette spacing, and calibrate recommends 9 or 10 bits when the canvas
can host those probes.
Co-authored-by: lfarrand <lfarrand@users.noreply.github.com>
The reason will be displayed to describe this comment to others. Learn more.
Cursor Bugbot has reviewed your changes and found 1 potential issue.
Autofix Details
Bugbot Autofix prepared a fix for the issue found in the latest run.
✅ Fixed: Heatmap clamps high floors to zero
RenderQuality no longer replaces a sparse confidence floor at or above AbsoluteSuspectDist with 0; margins the decoder still treats as confident stay green through that cutoff.
diff --git a/src/QrShard.Core/HeatmapRenderer.cs b/src/QrShard.Core/HeatmapRenderer.cs--- a/src/QrShard.Core/HeatmapRenderer.cs+++ b/src/QrShard.Core/HeatmapRenderer.cs@@ -76,25 +76,34 @@
/// </summary>
/// <param name="confidentDist">
/// Squared-distance floor from <see cref="GridSampler.ConfidenceFloorFor"/>. Margins at or
- /// below it paint the same green as an exact hit. The default matches the unmeasured fallback.+ /// below it paint the same green as an exact hit. A floor at or above+ /// <see cref="GridSampler.AbsoluteSuspectDist"/> (sparse 1–3 bit palettes) cannot be the+ /// green end — the scale would invert — and those samples stay unflagged through the cutoff,+ /// so green runs through it instead. The default matches the unmeasured fallback.
/// </param>
public void RenderQuality(Layout layout, int[] cellMargins, string outPath,
long confidentDist = GridSampler.DefaultConfidentDist)
{
int w = layout.GridW * CellPx, h = layout.GridH * CellPx;
var px = new Rgb24[w * h];
- // Same thresholds RecordConfidence uses: measured spacing → green, AbsoluteSuspectDist → red.+ // Measured spacing → green, AbsoluteSuspectDist → red, matching RecordConfidence.+ // A sparse floor sits above that cutoff. Collapsing it to 0 grades margin / cutoff,+ // so ordinary camera noise leaves green while the decoder still calls it confident.
double ambiguous = GridSampler.AbsoluteSuspectDist;
- double confident = confidentDist < 0 || confidentDist >= GridSampler.AbsoluteSuspectDist ? 0 : confidentDist;+ double confident = confidentDist < 0 ? 0 : confidentDist;+ double span = ambiguous - confident;
long cellIndex = 0;
for (int gy = 0; gy < layout.GridH; gy++)
{
for (int gx = 0; gx < layout.GridW; gx++, cellIndex++)
{
int margin = cellMargins[(int)cellIndex];
+ double t = span > 0+ ? Math.Clamp((margin - confident) / span, 0, 1)+ : margin > ambiguous ? 1 : 0;
var color = margin > ambiguous * 4
? new Rgb24(90, 0, 20) // far past any palette color — likely unreadable
- : Gradient(Math.Clamp((margin - confident) / (ambiguous - confident), 0, 1));+ : Gradient(t);
Fill(px, w, gx * CellPx, gy * CellPx, color);
}
}
diff --git a/tests/QrShard.Tests/TenBitConfidenceTests.cs b/tests/QrShard.Tests/TenBitConfidenceTests.cs--- a/tests/QrShard.Tests/TenBitConfidenceTests.cs+++ b/tests/QrShard.Tests/TenBitConfidenceTests.cs@@ -95,4 +95,33 @@
using var previous = Image.Load<Rgb24>(oldFloor);
Assert.Equal(previous[0, 0], previous[6, 0]);
}
++ [Fact]+ public void QualityHeatmap_SparseFloor_StaysGreenThroughTheAbsoluteCutoff()+ {+ var palette = new Palette().Build(2);+ long floor = GridSampler.ConfidenceFloorSquared(Palette.ClosestSquared(palette));+ Assert.True(floor >= GridSampler.AbsoluteSuspectDist);++ var layout = new Layout+ {+ BitsPerCell = 2,+ CellPx = 1,+ GridW = 3,+ GridH = 1,+ MetaH = 6,+ InnerW = 14,+ InnerH = 37,+ EccParity = 0,+ FinderModule = 0,+ };+ using var tmp = new TempDir();+ string path = tmp.File("quality.png");+ // 200 is unflagged camera noise; past the cutoff is an unconditional erasure.+ new HeatmapRenderer().RenderQuality(layout, [0, 200, 8000], path, confidentDist: floor);++ using var image = Image.Load<Rgb24>(path);+ Assert.Equal(image[0, 0], image[6, 0]);+ Assert.NotEqual(image[0, 0], image[12, 0]);+ }
}
calibrate rejects a resolution outside 700–16384 with the layout range
error. Interpolated rows use their own palette spacing, and the quality
heatmap clamps a sparse floor at the absolute-suspect distance.
Co-authored-by: lfarrand <lfarrand@users.noreply.github.com>
Accelerate exact palette lookup for uniform sampling
src/QrShard.Core/Palette.cs:12
Raising the palette to 1,024 colours makes the uniform sampler's existing exact fallback expensive on noisy or rescaled 10-bit captures. ClassifyUniform scans the whole measured palette for each non-exact cell (the 5-bit lookup only avoids this for exact hits); a supported 2160 px, 1 px-cell shard has roughly four million cells, so this can require billions of distance checks before error correction. Add an exact accelerated lookup for measured uniform palettes, preserving scan fallback and tie ordering where the palette cannot be indexed.
Measured product palettes classify through the separable index instead of
scanning every colour. Interpolated rows reuse one closest-pair workspace,
record their own confidence floors while sampling, and the quality heatmap
paints each row with that floor.
Co-authored-by: lfarrand <lfarrand@users.noreply.github.com>
The reason will be displayed to describe this comment to others. Learn more.
Cursor Bugbot has reviewed your changes and found 1 potential issue.
Bugbot Autofix prepared a fix for the issue found in the latest run.
✅ Fixed: Stale row floors after camera retry
A uniform sample now clears RowConfidentDist on the shared diagnostics object, so a camera retry no longer paints the quality map with the earlier interpolated attempt's per-row floors.
diff --git a/src/QrShard.Core/ShardDecoder.cs b/src/QrShard.Core/ShardDecoder.cs--- a/src/QrShard.Core/ShardDecoder.cs+++ b/src/QrShard.Core/ShardDecoder.cs@@ -689,12 +689,18 @@
byte[] cells = gridSampler.ReadDataGrid(bmp, inner, layout, palette, scratch,
out bool[]? suspectBytes, out byte[]? secondChoiceBytes, cellMargins);
// Floors were measured while sampling. Copy them out before this scratch is reused.
+ // Diagnose reuses one diagnostics object for the camera retry. A uniform sample must+ // drop any per-row floors from an earlier interpolated attempt: RenderQuality prefers+ // a non-null RowConfidentDist over the uniform floor measured for the margins shown.
if (diagnostics is { WantDetail: true })
{
if (palette.Interpolate)
diagnostics.RowConfidentDist = scratch.CopyRowFloors();
else
+ {+ diagnostics.RowConfidentDist = null;
diagnostics.QualityConfidentDist = scratch.UniformConfidenceFloor;
+ }
}
// v2 interleave: gather the permuted cell stream back into classic order so the whole
Diagnose reuses one diagnostics object. A uniform retry was leaving the
interpolated pass's row floors set, and the quality heatmap prefers those
over the retry's uniform floor.
Co-authored-by: lfarrand <lfarrand@users.noreply.github.com>
When two separately valid strips interpolate to the same colour for two indices (as the swapped-red test does at its midpoint), a cell captured at that exact colour has bestDist == 0 and a row floor of 0. This condition skips the runner-up search; even if it ran, secondDist < bestDist * 2 would reject the 0-vs-0 tie. The encoded index is unknowable, but neither the erasure flag nor the Chase alternative is populated. Detect exact duplicate-colour winners in a row and treat their zero-distance runner-up as ambiguous; extend the midpoint test with a sample that exactly matches the coincident colour.
Add efficient nearest-colour lookup for non-product palettes
src/QrShard.Core/GridSampler.cs:258
The new exact product index only applies when measured colours form a perfect Cartesian product. A small per-colour capture variation can make TryRebuild fail without making the strip unusable; then every non-exact uniform sample scans all 1,024 entries in ClassifyExact (the 5-bit cache cannot certify its winner). Interpolated non-product rows also scan the full palette for each sampled pixel, despite the new closest-pair workspace only accelerating spacing measurement. At about 250,000 cells per 2160px/cell-4 frame and up to nine samples each, this can mean billions of distance comparisons. Add a nearest-colour lookup for non-product palettes that preserves the scan's lowest-index tie, and use it in both uniform and interpolated sampling; cover slightly noisy measured strips in both paths.
An interpolated row can map two indices to one colour. A sample of that
colour has distance 0, so the runner-up scan was skipped, and a 0-vs-0
comparison would not have counted as a near-tie. The other index is now
an erasure and the Chase alternative.
Co-authored-by: lfarrand <lfarrand@users.noreply.github.com>
A 0-vs-0 tie stores a classification margin of 0, which the quality
heatmap paints as a confident hit. Sampling now records that tie even
when the image has no ECC, and the heatmap paints it red.
Co-authored-by: lfarrand <lfarrand@users.noreply.github.com>
Show flagged near-ties with a visible warning colour
src/QrShard.Core/HeatmapRenderer.cs:116
The 10-bit floor is 49, but a sample at distance 64 can already be a flagged near-tie (for example, red 9 between levels 0 and 17). Here the 49-to-4000 gradient paints it (41,190,45), almost identical to the confident green (40,190,45). Diagnose therefore hides the very ambiguity that triggered an erasure flag; the new heatmap test only asserts that the pixels differ. Record near-tie decisions per cell and show flagged cells with a visible warning colour, while retaining the separate exact-tie marker and the distance gradient for other cells.
Paint a flagged near-tie amber on the quality heatmap, distinct from a unique exact hit and from an exact-tie red. Product palettes take the runner-up from the channel index. Measured strips that are not a product use a spatial lookup that keeps the scan's lowest-index tie.
Co-authored-by: lfarrand <lfarrand@users.noreply.github.com>
The reason will be displayed to describe this comment to others. Learn more.
Copilot review overview
🔵 Needs a closer look
Decoder classification and confidence changes span multiple capture paths and need final human validation.
Review effort: Balanced Findings: None
Previously missed (1)
In code that hasn't changed since last review
Extend byte-window bit I/O paths for 9- and 10-bit cells
src/QrShard.Core/Palette.cs:12
Raising the limit to 10 activates BitStream.ReadCell and WriteCell's per-bit loops for every 9- or 10-bit cell. Rendering reads each cell and decoding writes each cell, so a high-resolution 1 px shard now performs tens of millions of extra shifts and bounds checks in these hot paths. Please extend the existing byte-window paths to handle up to 10 bits (including cells spanning three bytes and the end of a buffer), and cover those cases in the bit-I/O tests.
A 10-bit cell can cross three bytes. ReadCell and WriteCell now use a 24-bit window through 10 bits, including a short buffer, and the 1–8 bit layout stays the same.
Co-authored-by: lfarrand <lfarrand@users.noreply.github.com>
The reason will be displayed to describe this comment to others. Learn more.
Copilot review overview
🔵 Needs a closer look
Decoder classification and confidence changes affect data integrity across multiple paths and need final human review.
Review effort: Balanced Findings: None
This branch has not been deployed
No deployments
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Raises the accepted
bitsPerCellmaximum from 8 to 10. Values 9 and 10 already fit the existing 4-bit metadata field on versions 2, 3, and 4, so this does not add a stream-header flag and does not bump the metadata version. 16 still does not fit that nibble; 11–15 fit the field but stay outside the product cap.Shards encoded at 9 or 10 bits will not decode on older readers that only accept 1–8. Existing 1–8 shards are unchanged: the same palette spacing guarantee (minimum channel step of at least 32) still applies, and golden fixtures were not regenerated. Images stay 8 bits per channel. At 10 bits the palette is 16×8×8, so the minimum channel step inside 8-bit RGB is 17; that tighter step is intentional. On a 2160 px canvas the existing calibration strip is about 2 px per block.
Encode and decode share one cell-stream cap (
16384²bytes) and both reject a calibration strip that cannot give every palette colour at least one pixel, using the same rounded block edges the strip is drawn with. A 1 px grid at the maximum canvas still encodes at 8 bits and is rejected at 9 and 10.700×700at cell 3 and 10 bits is rejected because the strip is narrower than 1024 colours.Classification confidence follows measured palette spacing. The floor is the largest squared distance at which a runner-up one step away cannot be a near-tie, so exact hits still skip the second-nearest scan. At 10 bits (step 17) that floor is 49: a cell encoded as red 0 and captured as red 9 is classified as red 17 at distance 64 and flagged. A sample that lands exactly on another palette colour still has distance 0 and is not flagged. The quality heatmap uses the same floor, clamped at the absolute-suspect distance (4000) so a sparse 1-bit palette does not paint a trusted sample as weak. When the floor and that cap are both 4000 the gradient does not divide by zero. A flagged near-tie paints amber
(230, 140, 20), distinct from a unique exact hit and from an exact-tie red. Cells that are not flagged keep the distance gradient.A measured uniform palette that is still a channel product is classified by that index, with the same lowest-index tie as a full scan. The runner-up uses the same index: another combination of tied channel levels, or one channel stepped to its next-worse level. A palette that is not a product uses a 16-wide colour grid for both the winner and the runner-up. That lookup matches the full scan's lowest-index tie on uniform cells and on interpolated rows, and falls back to the scan when the neighbourhood cannot prune. A slightly noisy measured strip (one channel nudged, plus a duplicate colour) agrees with the scan. Interpolated rows that stay a product take their spacing from the channel gaps. Rows that do not reuse one closest-pair workspace instead of allocating a spatial hash per row. Those floors are recorded during sampling, so diagnose does not measure them again, and the quality heatmap paints each row with its own floor. A top row at floor 49 can still show distance 36 as confident while a tighter bottom row does not.
A camera retry reuses the same diagnostics object. Each sampling pass clears both floors before it samples, then records only the floor that palette measured. A uniform retry therefore does not keep the interpolated pass's row floors, which the quality heatmap would otherwise prefer over the retry's uniform floor.
Interpolated rows measure their own spacing. Swapping two adjacent 10-bit reds between the strips leaves both endpoints at step 17, but the midpoint entries coincide; a red-10 sample is then a 1-vs-1 near-tie and is flagged. A sample of that coincident colour is distance 0 from both indices. That tie is flagged as well, and the other index is the Chase alternative. The margin stored for the tie is still 0, so sampling also records a per-cell ambiguity mark and the quality heatmap paints that cell red. The mark is kept when the image has no ECC. An exact hit on a unique colour in the same row stays confident and green.
qrshard calibrate -r 20000reports the 700–16384 range error instead of claiming every probe is too small. In-range probes that the strip cannot host are still skipped.qrshard calibratescreen probes include 9 and 10 bits, and only the probes that resolution can host are written. Analysis still walks the full ladder, so a decoded 10-bit capture is recommended first. A 900 px canvas recommends-c 1 -b 9; 1280 px recommends-c 1 -b 10.ReadCellandWriteCelluse one 24-bit window for 1–10 bits. A 10-bit cell that starts on the last bit of a byte spans three bytes; a missing byte still reads as 0, and bits past the buffer are dropped. Widths 1–8 match the previous two-byte layout.Separability is still closest × 4000 ≥ widest. The check uses an exact closest pair and the bounding-box diagonal, and falls back to the exact widest pair only when that bound cannot decide.
The all-zero separable-palette length check was already right: lengths
1<<9and1<<10are valid Cartesian products, and1<<11and1<<16are declined. That assertion was left as it is.Test plan
dotnet build QrShard.slnx -c Release -warnaserror— succeeded, 0 warningsdotnet test tests/QrShard.Tests/QrShard.Tests.csproj -c Release(xunit.v3 MTP) — 1141 passed, 0 faileddotnet test tests/QrShard.Tests/QrShard.Tests.csproj -c Release -- --filter-class QrShard.Tests.BitIoTests— 28 passed, 0 faileddotnet test tests/QrShard.Tests/QrShard.Tests.csproj -c Release -- --filter-class QrShard.Tests.TenBitConfidenceTests— 9 passed, 0 faileddotnet test tests/QrShard.Tests/QrShard.Tests.csproj -c Release -- --filter-class QrShard.Tests.SeparablePaletteTests— 23 passed, 0 failedCovered by that run:
calibrate -r 20000exits 1 with the 700–16384 range error and does not say the probes are too small-c 1 -b 9and does not emit a 10-bit probe; at 1280 px it recommends-c 1 -b 10RowConfidentDist; the quality heatmap matches the uniform floor and not the stale row floorBitWriterat every alignment, including a 10-bit cell that spans three bytes; a short buffer drops the leftover bits and reads them as 0