mirror of
https://github.com/immich-app/immich.git
synced 2026-02-08 18:58:42 +03:00
244 lines
7.0 KiB
TypeScript
244 lines
7.0 KiB
TypeScript
import { AssetEditAction, AssetEditActionItem } from 'src/dtos/editing.dto';
|
|
import { AssetOcrResponseDto } from 'src/dtos/ocr.dto';
|
|
import { ImageDimensions } from 'src/types';
|
|
import { applyToPoint, compose, flipX, flipY, identity, Matrix, rotate, scale, translate } from 'transformation-matrix';
|
|
|
|
export const getOutputDimensions = (
|
|
edits: AssetEditActionItem[],
|
|
startingDimensions: ImageDimensions,
|
|
): ImageDimensions => {
|
|
let { width, height } = startingDimensions;
|
|
|
|
const crop = edits.find((edit) => edit.action === AssetEditAction.Crop);
|
|
if (crop) {
|
|
width = crop.parameters.width;
|
|
height = crop.parameters.height;
|
|
}
|
|
|
|
for (const edit of edits) {
|
|
if (edit.action === AssetEditAction.Rotate) {
|
|
const angleDegrees = edit.parameters.angle;
|
|
if (angleDegrees === 90 || angleDegrees === 270) {
|
|
[width, height] = [height, width];
|
|
}
|
|
}
|
|
}
|
|
|
|
return { width, height };
|
|
};
|
|
|
|
export const createAffineMatrix = (
|
|
edits: AssetEditActionItem[],
|
|
scalingParameters?: {
|
|
pointSpace: ImageDimensions;
|
|
targetSpace: ImageDimensions;
|
|
},
|
|
): Matrix => {
|
|
let scalingMatrix: Matrix = identity();
|
|
|
|
if (scalingParameters) {
|
|
const { pointSpace, targetSpace } = scalingParameters;
|
|
const scaleX = targetSpace.width / pointSpace.width;
|
|
scalingMatrix = scale(scaleX);
|
|
}
|
|
|
|
return compose(
|
|
scalingMatrix,
|
|
...edits.map((edit) => {
|
|
switch (edit.action) {
|
|
case 'rotate': {
|
|
const angleInRadians = (-edit.parameters.angle * Math.PI) / 180;
|
|
return rotate(angleInRadians);
|
|
}
|
|
case 'mirror': {
|
|
return edit.parameters.axis === 'horizontal' ? flipY() : flipX();
|
|
}
|
|
default: {
|
|
return identity();
|
|
}
|
|
}
|
|
}),
|
|
);
|
|
};
|
|
|
|
export type Point = { x: number; y: number };
|
|
|
|
type TransformState = {
|
|
points: Point[];
|
|
currentWidth: number;
|
|
currentHeight: number;
|
|
};
|
|
|
|
/**
|
|
* Transforms an array of points through a series of edit operations (crop, rotate, mirror).
|
|
* Points should be in absolute pixel coordinates relative to the starting dimensions.
|
|
*/
|
|
export const transformPoints = (
|
|
points: Point[],
|
|
edits: AssetEditActionItem[],
|
|
startingDimensions: ImageDimensions,
|
|
{ inverse = false } = {},
|
|
): TransformState => {
|
|
let currentWidth = startingDimensions.width;
|
|
let currentHeight = startingDimensions.height;
|
|
let transformedPoints = [...points];
|
|
|
|
// Handle crop first if not inverting
|
|
if (!inverse) {
|
|
const crop = edits.find((edit) => edit.action === 'crop');
|
|
if (crop) {
|
|
const { x: cropX, y: cropY, width: cropWidth, height: cropHeight } = crop.parameters;
|
|
transformedPoints = transformedPoints.map((p) => ({
|
|
x: p.x - cropX,
|
|
y: p.y - cropY,
|
|
}));
|
|
currentWidth = cropWidth;
|
|
currentHeight = cropHeight;
|
|
}
|
|
}
|
|
|
|
// Apply rotate and mirror transforms
|
|
const editSequence = inverse ? edits.toReversed() : edits;
|
|
for (const edit of editSequence) {
|
|
let matrix: Matrix = identity();
|
|
if (edit.action === 'rotate') {
|
|
const angleDegrees = edit.parameters.angle;
|
|
const angleRadians = (angleDegrees * Math.PI) / 180;
|
|
const newWidth = angleDegrees === 90 || angleDegrees === 270 ? currentHeight : currentWidth;
|
|
const newHeight = angleDegrees === 90 || angleDegrees === 270 ? currentWidth : currentHeight;
|
|
|
|
matrix = compose(
|
|
translate(newWidth / 2, newHeight / 2),
|
|
rotate(inverse ? -angleRadians : angleRadians),
|
|
translate(-currentWidth / 2, -currentHeight / 2),
|
|
);
|
|
|
|
currentWidth = newWidth;
|
|
currentHeight = newHeight;
|
|
} else if (edit.action === 'mirror') {
|
|
matrix = compose(
|
|
translate(currentWidth / 2, currentHeight / 2),
|
|
edit.parameters.axis === 'horizontal' ? flipY() : flipX(),
|
|
translate(-currentWidth / 2, -currentHeight / 2),
|
|
);
|
|
} else {
|
|
// Skip non-affine transformations
|
|
continue;
|
|
}
|
|
|
|
transformedPoints = transformedPoints.map((p) => applyToPoint(matrix, p));
|
|
}
|
|
|
|
// Handle crop last if inverting
|
|
if (inverse) {
|
|
const crop = edits.find((edit) => edit.action === 'crop');
|
|
if (crop) {
|
|
const { x: cropX, y: cropY } = crop.parameters;
|
|
transformedPoints = transformedPoints.map((p) => ({
|
|
x: p.x + cropX,
|
|
y: p.y + cropY,
|
|
}));
|
|
}
|
|
}
|
|
|
|
return {
|
|
points: transformedPoints,
|
|
currentWidth,
|
|
currentHeight,
|
|
};
|
|
};
|
|
|
|
type FaceBoundingBox = {
|
|
boundingBoxX1: number;
|
|
boundingBoxX2: number;
|
|
boundingBoxY1: number;
|
|
boundingBoxY2: number;
|
|
imageWidth: number;
|
|
imageHeight: number;
|
|
};
|
|
|
|
export const transformFaceBoundingBox = (
|
|
box: FaceBoundingBox,
|
|
edits: AssetEditActionItem[],
|
|
imageDimensions: ImageDimensions,
|
|
): FaceBoundingBox => {
|
|
if (edits.length === 0) {
|
|
return box;
|
|
}
|
|
|
|
const scaleX = imageDimensions.width / box.imageWidth;
|
|
const scaleY = imageDimensions.height / box.imageHeight;
|
|
|
|
const points: Point[] = [
|
|
{ x: box.boundingBoxX1 * scaleX, y: box.boundingBoxY1 * scaleY },
|
|
{ x: box.boundingBoxX2 * scaleX, y: box.boundingBoxY2 * scaleY },
|
|
];
|
|
|
|
const { points: transformedPoints, currentWidth, currentHeight } = transformPoints(points, edits, imageDimensions);
|
|
|
|
// Ensure x1,y1 is top-left and x2,y2 is bottom-right
|
|
const [p1, p2] = transformedPoints;
|
|
return {
|
|
boundingBoxX1: Math.min(p1.x, p2.x),
|
|
boundingBoxY1: Math.min(p1.y, p2.y),
|
|
boundingBoxX2: Math.max(p1.x, p2.x),
|
|
boundingBoxY2: Math.max(p1.y, p2.y),
|
|
imageWidth: currentWidth,
|
|
imageHeight: currentHeight,
|
|
};
|
|
};
|
|
|
|
const reorderQuadPointsForRotation = (points: Point[], rotationDegrees: number): Point[] => {
|
|
const [p1, p2, p3, p4] = points;
|
|
switch (rotationDegrees) {
|
|
case 90: {
|
|
return [p4, p1, p2, p3];
|
|
}
|
|
case 180: {
|
|
return [p3, p4, p1, p2];
|
|
}
|
|
case 270: {
|
|
return [p2, p3, p4, p1];
|
|
}
|
|
default: {
|
|
return points;
|
|
}
|
|
}
|
|
};
|
|
|
|
export const transformOcrBoundingBox = (
|
|
box: AssetOcrResponseDto,
|
|
edits: AssetEditActionItem[],
|
|
imageDimensions: ImageDimensions,
|
|
): AssetOcrResponseDto => {
|
|
if (edits.length === 0) {
|
|
return box;
|
|
}
|
|
|
|
const points: Point[] = [
|
|
{ x: box.x1 * imageDimensions.width, y: box.y1 * imageDimensions.height },
|
|
{ x: box.x2 * imageDimensions.width, y: box.y2 * imageDimensions.height },
|
|
{ x: box.x3 * imageDimensions.width, y: box.y3 * imageDimensions.height },
|
|
{ x: box.x4 * imageDimensions.width, y: box.y4 * imageDimensions.height },
|
|
];
|
|
|
|
const { points: transformedPoints, currentWidth, currentHeight } = transformPoints(points, edits, imageDimensions);
|
|
|
|
// Reorder points to maintain semantic ordering (topLeft, topRight, bottomRight, bottomLeft)
|
|
const netRotation = edits.find((e) => e.action == AssetEditAction.Rotate)?.parameters.angle ?? 0 % 360;
|
|
const reorderedPoints = reorderQuadPointsForRotation(transformedPoints, netRotation);
|
|
|
|
const [p1, p2, p3, p4] = reorderedPoints;
|
|
return {
|
|
...box,
|
|
x1: p1.x / currentWidth,
|
|
y1: p1.y / currentHeight,
|
|
x2: p2.x / currentWidth,
|
|
y2: p2.y / currentHeight,
|
|
x3: p3.x / currentWidth,
|
|
y3: p3.y / currentHeight,
|
|
x4: p4.x / currentWidth,
|
|
y4: p4.y / currentHeight,
|
|
};
|
|
};
|