4ea2618475
After rotateCalibration, map tape tick A→B through bakeTiltTransform into baked cal coords, orient overlay bbox with orientedRect(..., tapeAngle), and pass optional axis into buildOverlayPlan so length || tape and widths ⊥ tape.
141 lines
4.2 KiB
TypeScript
141 lines
4.2 KiB
TypeScript
/**
|
|
* 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'>;
|
|
}
|
|
|
|
/** Pure math for the same clockwise-about-centre bake used by rotateCalibration. */
|
|
export interface BakeTiltTransform {
|
|
/** True when tilt is ~0° (identity; no resample). */
|
|
identity: boolean;
|
|
tiltDeg: number;
|
|
srcW: number;
|
|
srcH: number;
|
|
outW: number;
|
|
outH: number;
|
|
/** Map a point from pre-bake source coords into baked canvas coords. */
|
|
mapPoint: (x: number, y: number) => [number, number];
|
|
}
|
|
|
|
/**
|
|
* Clockwise rotation about image centre; output canvas sized to AABB of rotated
|
|
* corners — identical sizing to rotateCalibration (canvas translate+rotate path).
|
|
*/
|
|
export function bakeTiltTransform(
|
|
srcW: number,
|
|
srcH: number,
|
|
tiltDeg: number,
|
|
): BakeTiltTransform {
|
|
const a = ((tiltDeg % 360) + 360) % 360;
|
|
if (a < 0.05 || a > 359.95) {
|
|
return {
|
|
identity: true,
|
|
tiltDeg: a,
|
|
srcW,
|
|
srcH,
|
|
outW: srcW,
|
|
outH: srcH,
|
|
mapPoint: (x, y) => [x, y],
|
|
};
|
|
}
|
|
const rad = (a * Math.PI) / 180;
|
|
const cos = Math.cos(rad);
|
|
const sin = Math.sin(rad);
|
|
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));
|
|
return {
|
|
identity: false,
|
|
tiltDeg: a,
|
|
srcW,
|
|
srcH,
|
|
outW,
|
|
outH,
|
|
mapPoint: (x, y) => {
|
|
const cx = x - srcW / 2;
|
|
const cy = y - srcH / 2;
|
|
return [cx * cos - cy * sin + outW / 2, cx * sin + cy * cos + outH / 2];
|
|
},
|
|
};
|
|
}
|
|
|
|
/**
|
|
* 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 xform = bakeTiltTransform(source.widthPx, source.heightPx, tiltDeg);
|
|
if (xform.identity) {
|
|
// No meaningful rotation — hand back the same bitmap reference; caller closes once.
|
|
return { bitmap, mask, source };
|
|
}
|
|
const { srcW, srcH, outW, outH, tiltDeg: a } = xform;
|
|
const rad = (a * Math.PI) / 180;
|
|
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 },
|
|
};
|
|
}
|