Skip to content

Commit 5c00684

Browse files
DustyShoelstein
andauthored
Feat(UI): Canvas high level transform smoothing (#8756)
* WIP transform smoothing controls * Fix transform smoothing control typings * High level resize algo for transformation * ESLint fix * format with prettier --------- Co-authored-by: Lincoln Stein <lincoln.stein@gmail.com>
1 parent d93ce6a commit 5c00684

File tree

7 files changed

+260
-33
lines changed

7 files changed

+260
-33
lines changed

invokeai/frontend/web/public/locales/en.json

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2517,6 +2517,13 @@
25172517
"fitModeContain": "Contain",
25182518
"fitModeCover": "Cover",
25192519
"fitModeFill": "Fill",
2520+
"smoothing": "Smoothing",
2521+
"smoothingDesc": "Apply a high-quality backend resample when committing transforms.",
2522+
"smoothingMode": "Resample Mode",
2523+
"smoothingModeBilinear": "Bilinear",
2524+
"smoothingModeBicubic": "Bicubic",
2525+
"smoothingModeHamming": "Hamming",
2526+
"smoothingModeLanczos": "Lanczos",
25202527
"reset": "Reset",
25212528
"apply": "Apply",
25222529
"cancel": "Cancel"

invokeai/frontend/web/src/features/controlLayers/components/Transform/Transform.tsx

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { useStore } from '@nanostores/react';
33
import { useFocusRegion, useIsRegionFocused } from 'common/hooks/focus';
44
import { CanvasOperationIsolatedLayerPreviewSwitch } from 'features/controlLayers/components/CanvasOperationIsolatedLayerPreviewSwitch';
55
import { TransformFitToBboxButtons } from 'features/controlLayers/components/Transform/TransformFitToBboxButtons';
6+
import { TransformSmoothingControls } from 'features/controlLayers/components/Transform/TransformSmoothingControls';
67
import { useCanvasManager } from 'features/controlLayers/contexts/CanvasManagerProviderGate';
78
import type { CanvasEntityAdapter } from 'features/controlLayers/konva/CanvasEntity/types';
89
import { useRegisteredHotkeys } from 'features/system/components/HotkeysModal/useHotkeyData';
@@ -59,6 +60,8 @@ const TransformContent = memo(({ adapter }: { adapter: CanvasEntityAdapter }) =>
5960
<CanvasOperationIsolatedLayerPreviewSwitch />
6061
</Flex>
6162

63+
<TransformSmoothingControls />
64+
6265
<TransformFitToBboxButtons adapter={adapter} />
6366

6467
<ButtonGroup isAttached={false} size="sm" w="full" alignItems="center">
Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
import { Flex, FormControl, FormLabel, Select, Switch, Tooltip } from '@invoke-ai/ui-library';
2+
import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
3+
import {
4+
selectTransformSmoothingEnabled,
5+
selectTransformSmoothingMode,
6+
settingsTransformSmoothingEnabledToggled,
7+
settingsTransformSmoothingModeChanged,
8+
type TransformSmoothingMode,
9+
} from 'features/controlLayers/store/canvasSettingsSlice';
10+
import type { ChangeEvent } from 'react';
11+
import { memo, useCallback } from 'react';
12+
import { useTranslation } from 'react-i18next';
13+
14+
export const TransformSmoothingControls = memo(() => {
15+
const { t } = useTranslation();
16+
const dispatch = useAppDispatch();
17+
const smoothingEnabled = useAppSelector(selectTransformSmoothingEnabled);
18+
const smoothingMode = useAppSelector(selectTransformSmoothingMode);
19+
20+
const onToggle = useCallback(() => {
21+
dispatch(settingsTransformSmoothingEnabledToggled());
22+
}, [dispatch]);
23+
24+
const onModeChange = useCallback(
25+
(e: ChangeEvent<HTMLSelectElement>) => {
26+
dispatch(settingsTransformSmoothingModeChanged(e.target.value as TransformSmoothingMode));
27+
},
28+
[dispatch]
29+
);
30+
31+
return (
32+
<Flex w="full" gap={4} alignItems="center" flexWrap="wrap">
33+
<Tooltip label={t('controlLayers.transform.smoothingDesc')}>
34+
<FormControl w="min-content">
35+
<FormLabel m={0}>{t('controlLayers.transform.smoothing')}</FormLabel>
36+
<Switch size="sm" isChecked={smoothingEnabled} onChange={onToggle} />
37+
</FormControl>
38+
</Tooltip>
39+
<FormControl flex={1} minW={200} maxW={280}>
40+
<FormLabel m={0}>{t('controlLayers.transform.smoothingMode')}</FormLabel>
41+
<Select size="sm" value={smoothingMode} onChange={onModeChange} isDisabled={!smoothingEnabled}>
42+
<option value="bilinear">{t('controlLayers.transform.smoothingModeBilinear')}</option>
43+
<option value="bicubic">{t('controlLayers.transform.smoothingModeBicubic')}</option>
44+
<option value="hamming">{t('controlLayers.transform.smoothingModeHamming')}</option>
45+
<option value="lanczos">{t('controlLayers.transform.smoothingModeLanczos')}</option>
46+
</Select>
47+
</FormControl>
48+
</Flex>
49+
);
50+
});
51+
52+
TransformSmoothingControls.displayName = 'TransformSmoothingControls';

invokeai/frontend/web/src/features/controlLayers/konva/CanvasEntity/CanvasEntityObjectRenderer.ts

Lines changed: 45 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -556,37 +556,67 @@ export class CanvasEntityObjectRenderer extends CanvasModuleBase {
556556
this.rasterCacheKeys.clear();
557557
};
558558

559-
cloneObjectGroup = (arg: { attrs?: GroupConfig } = {}): Konva.Group => {
560-
const { attrs } = arg;
559+
cloneObjectGroup = (
560+
arg: { attrs?: GroupConfig; cache?: { pixelRatio?: number; imageSmoothingEnabled?: boolean } } = {}
561+
): Konva.Group => {
562+
const { attrs, cache } = arg;
561563
const clone = this.konva.objectGroup.clone();
562564
if (attrs) {
563565
clone.setAttrs(attrs);
564566
}
565567
if (clone.hasChildren()) {
566-
clone.cache({ pixelRatio: 1, imageSmoothingEnabled: false });
568+
const { pixelRatio = 1, imageSmoothingEnabled = false } = cache ?? {};
569+
clone.cache({ pixelRatio, imageSmoothingEnabled });
567570
}
568571
return clone;
569572
};
570573

571-
getCanvas = (arg: { rect?: Rect; attrs?: GroupConfig; bg?: string } = {}): HTMLCanvasElement => {
572-
const { rect, attrs, bg } = arg;
573-
const clone = this.cloneObjectGroup({ attrs });
574-
const canvas = konvaNodeToCanvas({ node: clone, rect, bg });
574+
getCanvas = (
575+
arg: {
576+
rect?: Rect;
577+
attrs?: GroupConfig;
578+
bg?: string;
579+
imageSmoothingEnabled?: boolean;
580+
pixelRatio?: number;
581+
cache?: { pixelRatio?: number; imageSmoothingEnabled?: boolean };
582+
} = {}
583+
): HTMLCanvasElement => {
584+
const { rect, attrs, bg, imageSmoothingEnabled, pixelRatio, cache } = arg;
585+
const clone = this.cloneObjectGroup({ attrs, cache });
586+
const canvas = konvaNodeToCanvas({ node: clone, rect, bg, imageSmoothingEnabled, pixelRatio });
575587
clone.destroy();
576588
return canvas;
577589
};
578590

579-
getBlob = async (arg: { rect?: Rect; attrs?: GroupConfig; bg?: string } = {}): Promise<Blob> => {
580-
const { rect, attrs, bg } = arg;
581-
const clone = this.cloneObjectGroup({ attrs });
582-
const blob = await konvaNodeToBlob({ node: clone, rect, bg });
591+
getBlob = async (
592+
arg: {
593+
rect?: Rect;
594+
attrs?: GroupConfig;
595+
bg?: string;
596+
imageSmoothingEnabled?: boolean;
597+
pixelRatio?: number;
598+
cache?: { pixelRatio?: number; imageSmoothingEnabled?: boolean };
599+
} = {}
600+
): Promise<Blob> => {
601+
const { rect, attrs, bg, imageSmoothingEnabled, pixelRatio, cache } = arg;
602+
const clone = this.cloneObjectGroup({ attrs, cache });
603+
const blob = await konvaNodeToBlob({ node: clone, rect, bg, imageSmoothingEnabled, pixelRatio });
583604
return blob;
584605
};
585606

586-
getImageData = (arg: { rect?: Rect; attrs?: GroupConfig; bg?: string } = {}): ImageData => {
587-
const { rect, attrs, bg } = arg;
588-
const clone = this.cloneObjectGroup({ attrs });
589-
const imageData = konvaNodeToImageData({ node: clone, rect, bg });
607+
getImageData = (
608+
arg: {
609+
rect?: Rect;
610+
attrs?: GroupConfig;
611+
bg?: string;
612+
imageSmoothingEnabled?: boolean;
613+
pixelRatio?: number;
614+
cache?: { pixelRatio?: number; imageSmoothingEnabled?: boolean };
615+
} = {}
616+
): ImageData => {
617+
const { rect, attrs, bg, imageSmoothingEnabled, pixelRatio, cache } = arg;
618+
const clone = this.cloneObjectGroup({ attrs, cache });
619+
const imageData = konvaNodeToImageData({ node: clone, rect, bg, imageSmoothingEnabled, pixelRatio });
590620
clone.destroy();
591621
return imageData;
592622
};

invokeai/frontend/web/src/features/controlLayers/konva/CanvasEntity/CanvasEntityTransformer.ts

Lines changed: 97 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -14,14 +14,19 @@ import {
1414
offsetCoord,
1515
roundRect,
1616
} from 'features/controlLayers/konva/util';
17+
import type { TransformSmoothingMode } from 'features/controlLayers/store/canvasSettingsSlice';
1718
import { selectSelectedEntityIdentifier } from 'features/controlLayers/store/selectors';
1819
import type { Coordinate, LifecycleCallback, Rect, RectWithRotation } from 'features/controlLayers/store/types';
20+
import { imageDTOToImageObject } from 'features/controlLayers/store/util';
21+
import { Graph } from 'features/nodes/util/graph/generation/Graph';
1922
import { toast } from 'features/toast/toast';
2023
import Konva from 'konva';
2124
import type { GroupConfig } from 'konva/lib/Group';
2225
import { atom } from 'nanostores';
2326
import type { Logger } from 'roarr';
2427
import { serializeError } from 'serialize-error';
28+
import { uploadImage } from 'services/api/endpoints/images';
29+
import type { ImageDTO } from 'services/api/types';
2530
import { assert } from 'tsafe';
2631

2732
type CanvasEntityTransformerConfig = {
@@ -90,6 +95,8 @@ const DEFAULT_CONFIG: CanvasEntityTransformerConfig = {
9095
ROTATE_ANCHOR_SIZE: 12,
9196
};
9297

98+
const TRANSFORM_SMOOTHING_PIXEL_RATIO = 2;
99+
93100
export class CanvasEntityTransformer extends CanvasModuleBase {
94101
readonly type = 'entity_transformer';
95102
readonly id: string;
@@ -807,23 +814,105 @@ export class CanvasEntityTransformer extends CanvasModuleBase {
807814
this.log.debug('Applying transform');
808815
this.$isProcessing.set(true);
809816
this._setInteractionMode('off');
810-
const rect = this.getRelativeRect();
811-
const rasterizeResult = await withResultAsync(() =>
812-
this.parent.renderer.rasterize({
813-
rect: roundRect(rect),
814-
replaceObjects: true,
815-
ignoreCache: true,
817+
const rect = roundRect(this.getRelativeRect());
818+
const { transformSmoothingEnabled, transformSmoothingMode } = this.manager.stateApi.getSettings();
819+
if (!transformSmoothingEnabled) {
820+
const rasterizeResult = await withResultAsync(() =>
821+
this.parent.renderer.rasterize({
822+
rect,
823+
replaceObjects: true,
824+
ignoreCache: true,
825+
attrs: { opacity: 1, filters: [] },
826+
})
827+
);
828+
if (rasterizeResult.isErr()) {
829+
toast({ status: 'error', title: 'Failed to apply transform' });
830+
this.log.error({ error: serializeError(rasterizeResult.error) }, 'Failed to rasterize entity');
831+
}
832+
this.requestRectCalculation();
833+
this.stopTransform();
834+
return;
835+
}
836+
837+
const rasterizeResult = await withResultAsync(async () => {
838+
const blob = await this.parent.renderer.getBlob({
839+
rect,
816840
attrs: { opacity: 1, filters: [] },
817-
})
818-
);
841+
imageSmoothingEnabled: true,
842+
pixelRatio: TRANSFORM_SMOOTHING_PIXEL_RATIO,
843+
cache: {
844+
imageSmoothingEnabled: true,
845+
pixelRatio: TRANSFORM_SMOOTHING_PIXEL_RATIO,
846+
},
847+
});
848+
849+
return await uploadImage({
850+
file: new File([blob], `${this.parent.id}_transform.png`, { type: 'image/png' }),
851+
image_category: 'other',
852+
is_intermediate: true,
853+
silent: true,
854+
});
855+
});
856+
819857
if (rasterizeResult.isErr()) {
820858
toast({ status: 'error', title: 'Failed to apply transform' });
821859
this.log.error({ error: serializeError(rasterizeResult.error) }, 'Failed to rasterize entity');
860+
this.requestRectCalculation();
861+
this.stopTransform();
862+
return;
822863
}
864+
865+
const resizeResult = await withResultAsync(() =>
866+
this.manager.stateApi.runGraphAndReturnImageOutput({
867+
...CanvasEntityTransformer.buildTransformSmoothingGraph(rasterizeResult.value, rect, transformSmoothingMode),
868+
options: {
869+
prepend: true,
870+
},
871+
})
872+
);
873+
874+
if (resizeResult.isErr()) {
875+
toast({ status: 'error', title: 'Failed to apply transform' });
876+
this.log.error({ error: serializeError(resizeResult.error) }, 'Failed to smooth transformed entity');
877+
this.requestRectCalculation();
878+
this.stopTransform();
879+
return;
880+
}
881+
882+
const imageObject = imageDTOToImageObject(resizeResult.value);
883+
await this.parent.bufferRenderer.setBuffer(imageObject);
884+
this.parent.bufferRenderer.commitBuffer({ pushToState: false });
885+
this.manager.stateApi.rasterizeEntity({
886+
entityIdentifier: this.parent.entityIdentifier,
887+
imageObject,
888+
position: {
889+
x: rect.x,
890+
y: rect.y,
891+
},
892+
replaceObjects: true,
893+
});
823894
this.requestRectCalculation();
824895
this.stopTransform();
825896
};
826897

898+
private static buildTransformSmoothingGraph = (
899+
imageDTO: ImageDTO,
900+
rect: Rect,
901+
resampleMode: TransformSmoothingMode
902+
): { graph: Graph; outputNodeId: string } => {
903+
const graph = new Graph(getPrefixedId('transform_smoothing'));
904+
const outputNodeId = getPrefixedId('transform_smoothing_resize');
905+
graph.addNode({
906+
id: outputNodeId,
907+
type: 'img_resize',
908+
image: { image_name: imageDTO.image_name },
909+
width: rect.width,
910+
height: rect.height,
911+
resample_mode: resampleMode,
912+
});
913+
return { graph, outputNodeId };
914+
};
915+
827916
resetTransform = () => {
828917
this.resetScale();
829918
this.updatePosition();

invokeai/frontend/web/src/features/controlLayers/konva/util.ts

Lines changed: 28 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -368,9 +368,15 @@ export const dataURLToImageData = (dataURL: string, width: number, height: numbe
368368
});
369369
};
370370

371-
export const konvaNodeToCanvas = (arg: { node: Konva.Node; rect?: Rect; bg?: string }): HTMLCanvasElement => {
372-
const { node, rect, bg } = arg;
373-
const canvas = node.toCanvas({ ...(rect ?? {}), imageSmoothingEnabled: false, pixelRatio: 1 });
371+
export const konvaNodeToCanvas = (arg: {
372+
node: Konva.Node;
373+
rect?: Rect;
374+
bg?: string;
375+
imageSmoothingEnabled?: boolean;
376+
pixelRatio?: number;
377+
}): HTMLCanvasElement => {
378+
const { node, rect, bg, imageSmoothingEnabled = false, pixelRatio = 1 } = arg;
379+
const canvas = node.toCanvas({ ...(rect ?? {}), imageSmoothingEnabled, pixelRatio });
374380

375381
if (!bg) {
376382
return canvas;
@@ -382,7 +388,7 @@ export const konvaNodeToCanvas = (arg: { node: Konva.Node; rect?: Rect; bg?: str
382388
bgCanvas.height = canvas.height;
383389
const bgCtx = bgCanvas.getContext('2d');
384390
assert(bgCtx !== null, 'bgCtx is null');
385-
bgCtx.imageSmoothingEnabled = false;
391+
bgCtx.imageSmoothingEnabled = imageSmoothingEnabled;
386392
bgCtx.fillStyle = bg;
387393
bgCtx.fillRect(0, 0, bgCanvas.width, bgCanvas.height);
388394
bgCtx.drawImage(canvas, 0, 0);
@@ -419,9 +425,15 @@ export const canvasToImageData = (canvas: HTMLCanvasElement): ImageData => {
419425
* @param rect - The bounding box to crop to
420426
* @returns A Promise that resolves with ImageData object of the node cropped to the bounding box
421427
*/
422-
export const konvaNodeToImageData = (arg: { node: Konva.Node; rect?: Rect; bg?: string }): ImageData => {
423-
const { node, rect, bg } = arg;
424-
const canvas = konvaNodeToCanvas({ node, rect, bg });
428+
export const konvaNodeToImageData = (arg: {
429+
node: Konva.Node;
430+
rect?: Rect;
431+
bg?: string;
432+
imageSmoothingEnabled?: boolean;
433+
pixelRatio?: number;
434+
}): ImageData => {
435+
const { node, rect, bg, imageSmoothingEnabled, pixelRatio } = arg;
436+
const canvas = konvaNodeToCanvas({ node, rect, bg, imageSmoothingEnabled, pixelRatio });
425437
return canvasToImageData(canvas);
426438
};
427439

@@ -431,9 +443,15 @@ export const konvaNodeToImageData = (arg: { node: Konva.Node; rect?: Rect; bg?:
431443
* @param rect - The bounding box to crop to
432444
* @returns A Promise that resolves to the Blob or null,
433445
*/
434-
export const konvaNodeToBlob = (arg: { node: Konva.Node; rect?: Rect; bg?: string }): Promise<Blob> => {
435-
const { node, rect, bg } = arg;
436-
const canvas = konvaNodeToCanvas({ node, rect, bg });
446+
export const konvaNodeToBlob = (arg: {
447+
node: Konva.Node;
448+
rect?: Rect;
449+
bg?: string;
450+
imageSmoothingEnabled?: boolean;
451+
pixelRatio?: number;
452+
}): Promise<Blob> => {
453+
const { node, rect, bg, imageSmoothingEnabled, pixelRatio } = arg;
454+
const canvas = konvaNodeToCanvas({ node, rect, bg, imageSmoothingEnabled, pixelRatio });
437455
return canvasToBlob(canvas);
438456
};
439457

0 commit comments

Comments
 (0)