feat(capture): shared crop_fill + tape geometry for #99
- One crop_fill (default 0.9) replaces duplicated FILL in inventory/overlay - Thin localStorage settings store; crop % only moves long-axis end insets - Pure tapeGeometry: fixed stage line, stage↔image map, tick span → px - measure() prefers tape tick span for scale; maskSpanAlong demoted - rotateCalibration bakes cal tilt into inventory + overlay only - Fix stale 80%-fill comment on slabBounds
This commit is contained in:
@@ -0,0 +1,92 @@
|
||||
/**
|
||||
* Bake calibration photo tilt into a rotated bitmap + mask (#99 transform scope).
|
||||
* Used only for the calibration inventory PNG + dimension-overlay clone.
|
||||
* Batch siblings are never passed through here.
|
||||
*/
|
||||
import type { Mask, Source } from '../capture/capture.types';
|
||||
import { minAreaRect, slabBounds } from '../geometry/rect';
|
||||
|
||||
export interface RotatedCal {
|
||||
bitmap: ImageBitmap;
|
||||
mask: Mask;
|
||||
source: Pick<Source, 'widthPx' | 'heightPx'>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Rotate source clockwise by tiltDeg (same sign as CSS/canvas rotate).
|
||||
* Returns a new bitmap the caller must close, plus recomputed mask geometry.
|
||||
* Identity (≈0°) returns clones of inputs without resampling.
|
||||
*/
|
||||
export async function rotateCalibration(
|
||||
bitmap: ImageBitmap,
|
||||
mask: Mask,
|
||||
source: Pick<Source, 'widthPx' | 'heightPx'>,
|
||||
tiltDeg: number,
|
||||
): Promise<RotatedCal> {
|
||||
const a = ((tiltDeg % 360) + 360) % 360;
|
||||
if (a < 0.05 || a > 359.95) {
|
||||
// No meaningful rotation — hand back the same bitmap reference; caller closes once.
|
||||
return { bitmap, mask, source };
|
||||
}
|
||||
const srcW = source.widthPx;
|
||||
const srcH = source.heightPx;
|
||||
const rad = (a * Math.PI) / 180;
|
||||
const cos = Math.cos(rad);
|
||||
const sin = Math.sin(rad);
|
||||
// Axis-aligned bounds of rotated rectangle corners.
|
||||
const corners = [
|
||||
[0, 0],
|
||||
[srcW, 0],
|
||||
[srcW, srcH],
|
||||
[0, srcH],
|
||||
].map(([x, y]) => {
|
||||
const cx = x - srcW / 2;
|
||||
const cy = y - srcH / 2;
|
||||
return [cx * cos - cy * sin, cx * sin + cy * cos] as [number, number];
|
||||
});
|
||||
let minX = Infinity, maxX = -Infinity, minY = Infinity, maxY = -Infinity;
|
||||
for (const [x, y] of corners) {
|
||||
if (x < minX) minX = x;
|
||||
if (x > maxX) maxX = x;
|
||||
if (y < minY) minY = y;
|
||||
if (y > maxY) maxY = y;
|
||||
}
|
||||
const outW = Math.max(1, Math.ceil(maxX - minX));
|
||||
const outH = Math.max(1, Math.ceil(maxY - minY));
|
||||
const canvas = new OffscreenCanvas(outW, outH);
|
||||
const ctx = canvas.getContext('2d')!;
|
||||
ctx.translate(outW / 2, outH / 2);
|
||||
ctx.rotate(rad);
|
||||
ctx.drawImage(bitmap, -srcW / 2, -srcH / 2);
|
||||
|
||||
// Nearest-neighbour resample of the bitset into the rotated frame.
|
||||
const bitset = new Uint8Array(outW * outH);
|
||||
const invCos = Math.cos(-rad);
|
||||
const invSin = Math.sin(-rad);
|
||||
for (let y = 0; y < outH; y++) {
|
||||
for (let x = 0; x < outW; x++) {
|
||||
const dx = x - outW / 2;
|
||||
const dy = y - outH / 2;
|
||||
const sx = Math.round(dx * invCos - dy * invSin + srcW / 2);
|
||||
const sy = Math.round(dx * invSin + dy * invCos + srcH / 2);
|
||||
if (sx < 0 || sy < 0 || sx >= srcW || sy >= srcH) continue;
|
||||
bitset[y * outW + x] = mask.bitset[sy * srcW + sx];
|
||||
}
|
||||
}
|
||||
let slabPx = 0;
|
||||
for (let i = 0; i < bitset.length; i++) if (bitset[i]) slabPx++;
|
||||
const bounds = slabBounds(bitset, outW, outH);
|
||||
const bbox = minAreaRect(bitset, outW, outH) ?? mask.bbox;
|
||||
const outBmp = canvas.transferToImageBitmap();
|
||||
return {
|
||||
bitmap: outBmp,
|
||||
mask: {
|
||||
params: mask.params,
|
||||
slabPx,
|
||||
bitset,
|
||||
bbox,
|
||||
slabBounds: bounds,
|
||||
},
|
||||
source: { widthPx: outW, heightPx: outH },
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user