Spaces:
Running
Running
| <svelte:options accessors={true} /> | |
| <script lang="ts"> | |
| import { onMount, onDestroy, tick } from "svelte"; | |
| import { | |
| Application, | |
| Container, | |
| Graphics, | |
| Text, | |
| Sprite, | |
| Texture, | |
| TextStyle, | |
| } from "pixi.js"; | |
| import type { Gradio } from "@gradio/utils"; | |
| import { Block, BlockLabel } from "@gradio/atoms"; | |
| import { Image as ImageIcon } from "@gradio/icons"; | |
| import { StatusTracker } from "@gradio/statustracker"; | |
| import type { LoadingStatus } from "@gradio/statustracker"; | |
| import type { FileData } from "@gradio/client"; | |
| export let elem_id = ""; | |
| export let elem_classes: string[] = []; | |
| export let visible: boolean | "hidden" = true; | |
| export let value: { | |
| image: FileData; | |
| polygons: Array<{ | |
| id: string; | |
| coordinates: number[][]; | |
| color: string; | |
| mask_opacity?: number; | |
| stroke_width?: number; | |
| stroke_opacity?: number; | |
| selected_mask_opacity?: number; | |
| selected_stroke_opacity?: number; | |
| display_text?: string | null; | |
| display_font_size?: number | null; | |
| display_text_color?: string; | |
| }>; | |
| selected_polygons?: string[] | null; | |
| } | null = null; | |
| export let label: string; | |
| export let show_label: boolean; | |
| export let height: number | string | undefined = undefined; | |
| export let width: number | string | undefined = undefined; | |
| export let container = true; | |
| export let scale: number | null = null; | |
| export let min_width: number | undefined = undefined; | |
| export let loading_status: LoadingStatus; | |
| export let root: string; | |
| export let gradio: Gradio<{ | |
| change: never; | |
| upload: never; | |
| clear: never; | |
| select: { index: number | null; value: any }; | |
| clear_status: LoadingStatus; | |
| }>; | |
| let canvasContainer: HTMLDivElement; | |
| let app: Application; | |
| let imageSprite: Sprite | null = null; | |
| let polygonGraphics: Map<string, Graphics> = new Map(); | |
| let polygonTexts: Map<string, Text> = new Map(); | |
| let textContainer: Container | null = null; | |
| let selectedPolygonIds: string[] = []; | |
| let viewportContainer: Container | null = null; | |
| let isDragging = false; | |
| let lastPointerPosition = { x: 0, y: 0 }; | |
| let initialScale = 1; | |
| let initialPosition = { x: 0, y: 0 }; | |
| let minScale = 0.5; | |
| let maxScale = 3; | |
| let isMouseOverCanvas = false; | |
| type ImageRect = { | |
| left: number; | |
| top: number; | |
| right: number; | |
| bottom: number; | |
| width: number; | |
| height: number; | |
| naturalWidth: number; | |
| naturalHeight: number; | |
| }; | |
| let imageRect: ImageRect = { | |
| left: 0, | |
| top: 0, | |
| right: 1, | |
| bottom: 1, | |
| width: 1, | |
| height: 1, | |
| naturalWidth: 2, | |
| naturalHeight: 2, | |
| }; | |
| $: (value, handleValueChange()); | |
| async function handleValueChange() { | |
| if (!app || !value) return; | |
| selectedPolygonIds = value.selected_polygons || []; | |
| await renderAnnotations(); | |
| } | |
| function updateSelection(newSelectedIds: string[]) { | |
| if (!value || !imageSprite) return; | |
| polygonGraphics.forEach((graphics, polygonId) => { | |
| const polygon = value.polygons.find((p) => p.id === polygonId); | |
| if (!polygon) return; | |
| const originalMaskAlpha = polygon.mask_opacity ?? 0.2; | |
| const selectedMaskAlpha = polygon.selected_mask_opacity ?? 0.5; | |
| const originalStrokeAlpha = polygon.stroke_opacity ?? 0.6; | |
| const selectedStrokeAlpha = polygon.selected_stroke_opacity ?? 1.0; | |
| const strokeWidth = polygon.stroke_width ?? 0.7; | |
| graphics.clear(); | |
| if (newSelectedIds.includes(polygonId)) { | |
| drawPolygonPath( | |
| graphics, | |
| polygon, | |
| selectedMaskAlpha, | |
| strokeWidth, | |
| selectedStrokeAlpha, | |
| ); | |
| } else { | |
| drawPolygonPath( | |
| graphics, | |
| polygon, | |
| originalMaskAlpha, | |
| strokeWidth, | |
| originalStrokeAlpha, | |
| ); | |
| } | |
| }); | |
| } | |
| function handlePolygonSelection(polygonId: string, event: any) { | |
| if (!value) return; | |
| const isMultiSelect = event.ctrlKey || event.metaKey; | |
| if (selectedPolygonIds.includes(polygonId)) { | |
| const newSelectedIds = selectedPolygonIds.filter( | |
| (id) => id !== polygonId, | |
| ); | |
| updateSelection(newSelectedIds); | |
| selectedPolygonIds = newSelectedIds; | |
| gradio.dispatch("select", { | |
| index: | |
| newSelectedIds.length > 0 | |
| ? value.polygons.findIndex( | |
| (p) => | |
| p.id === | |
| newSelectedIds[ | |
| newSelectedIds.length - 1 | |
| ], | |
| ) | |
| : null, | |
| value: | |
| newSelectedIds.length > 0 | |
| ? newSelectedIds | |
| : null, | |
| }); | |
| return; | |
| } | |
| let newSelectedIds: string[]; | |
| if (isMultiSelect) { | |
| newSelectedIds = [...selectedPolygonIds, polygonId]; | |
| } else { | |
| newSelectedIds = [polygonId]; | |
| } | |
| updateSelection(newSelectedIds); | |
| selectedPolygonIds = newSelectedIds; | |
| gradio.dispatch("select", { | |
| index: value.polygons.findIndex( | |
| (p) => p.id === polygonId, | |
| ), | |
| value: newSelectedIds, | |
| }); | |
| } | |
| async function initPixiApp() { | |
| if (!canvasContainer) return; | |
| const containerWidth = canvasContainer.clientWidth || 800; | |
| const containerHeight = canvasContainer.clientHeight || 600; | |
| app = new Application(); | |
| await app.init({ | |
| width: containerWidth, | |
| height: containerHeight, | |
| backgroundColor: 0xf0f0f0, | |
| antialias: true, | |
| resolution: window.devicePixelRatio || 1, | |
| autoDensity: true, | |
| }); | |
| canvasContainer.appendChild(app.canvas as HTMLCanvasElement); | |
| app.stage.eventMode = "static"; | |
| app.stage.hitArea = app.screen; | |
| viewportContainer = new Container(); | |
| viewportContainer.eventMode = "static"; | |
| app.stage.addChild(viewportContainer); | |
| setupControls(); | |
| } | |
| function setupControls() { | |
| if (!app || !viewportContainer) return; | |
| window.addEventListener("keydown", handleKeydown); | |
| // Track mouse hover state for canvas area | |
| if (canvasContainer) { | |
| canvasContainer.addEventListener("mouseenter", () => { | |
| isMouseOverCanvas = true; | |
| }); | |
| canvasContainer.addEventListener("mouseleave", () => { | |
| isMouseOverCanvas = false; | |
| }); | |
| } | |
| app.stage.on("pointerdown", (event) => { | |
| if (event.button === 1 || (event.button === 0 && event.shiftKey)) { | |
| isDragging = true; | |
| lastPointerPosition = { x: event.global.x, y: event.global.y }; | |
| app.canvas.style.cursor = "grabbing"; | |
| } | |
| }); | |
| app.stage.on("pointermove", (event) => { | |
| if (isDragging && viewportContainer) { | |
| const dx = event.global.x - lastPointerPosition.x; | |
| const dy = event.global.y - lastPointerPosition.y; | |
| viewportContainer.x += dx; | |
| viewportContainer.y += dy; | |
| lastPointerPosition = { x: event.global.x, y: event.global.y }; | |
| } | |
| }); | |
| app.stage.on("pointerup", () => { | |
| isDragging = false; | |
| app.canvas.style.cursor = "default"; | |
| }); | |
| app.stage.on("pointerupoutside", () => { | |
| isDragging = false; | |
| app.canvas.style.cursor = "default"; | |
| }); | |
| app.canvas.addEventListener("wheel", handleWheel, { passive: false }); | |
| } | |
| function handleKeydown(event: KeyboardEvent) { | |
| if (!viewportContainer || !isMouseOverCanvas) return; | |
| switch(event.key) { | |
| case "=": | |
| case "+": | |
| zoomIn(); | |
| event.preventDefault(); | |
| break; | |
| case "-": | |
| case "_": | |
| zoomOut(); | |
| event.preventDefault(); | |
| break; | |
| case "0": | |
| if (event.ctrlKey || event.metaKey) { | |
| resetView(); | |
| event.preventDefault(); | |
| } | |
| break; | |
| case "ArrowLeft": | |
| viewportContainer!.x += 20; | |
| event.preventDefault(); | |
| break; | |
| case "ArrowRight": | |
| viewportContainer!.x -= 20; | |
| event.preventDefault(); | |
| break; | |
| case "ArrowUp": | |
| viewportContainer!.y += 20; | |
| event.preventDefault(); | |
| break; | |
| case "ArrowDown": | |
| viewportContainer!.y -= 20; | |
| event.preventDefault(); | |
| break; | |
| } | |
| } | |
| function handleWheel(event: WheelEvent) { | |
| if (!viewportContainer || !app) return; | |
| event.preventDefault(); | |
| const delta = event.deltaY < 0 ? 1.05 : 0.95; | |
| const newScale = viewportContainer!.scale.x * delta; | |
| if (newScale >= minScale && newScale <= maxScale) { | |
| const worldPos = { | |
| x: (event.offsetX - viewportContainer!.x) / viewportContainer!.scale.x, | |
| y: (event.offsetY - viewportContainer!.y) / viewportContainer!.scale.y, | |
| }; | |
| viewportContainer!.scale.x = newScale; | |
| viewportContainer!.scale.y = newScale; | |
| viewportContainer!.x = event.offsetX - worldPos.x * newScale; | |
| viewportContainer!.y = event.offsetY - worldPos.y * newScale; | |
| } | |
| } | |
| function zoomIn() { | |
| if (!viewportContainer || !app) return; | |
| const newScale = Math.min(viewportContainer!.scale.x * 1.1, maxScale); | |
| const center = { x: app.screen.width / 2, y: app.screen.height / 2 }; | |
| const worldPos = { | |
| x: (center.x - viewportContainer!.x) / viewportContainer!.scale.x, | |
| y: (center.y - viewportContainer!.y) / viewportContainer!.scale.y, | |
| }; | |
| viewportContainer!.scale.x = newScale; | |
| viewportContainer!.scale.y = newScale; | |
| viewportContainer!.x = center.x - worldPos.x * newScale; | |
| viewportContainer!.y = center.y - worldPos.y * newScale; | |
| } | |
| function zoomOut() { | |
| if (!viewportContainer || !app) return; | |
| const newScale = Math.max(viewportContainer!.scale.x * 0.9, minScale); | |
| const center = { x: app.screen.width / 2, y: app.screen.height / 2 }; | |
| const worldPos = { | |
| x: (center.x - viewportContainer!.x) / viewportContainer!.scale.x, | |
| y: (center.y - viewportContainer!.y) / viewportContainer!.scale.y, | |
| }; | |
| viewportContainer!.scale.x = newScale; | |
| viewportContainer!.scale.y = newScale; | |
| viewportContainer!.x = center.x - worldPos.x * newScale; | |
| viewportContainer!.y = center.y - worldPos.y * newScale; | |
| } | |
| function resetView() { | |
| if (!viewportContainer) return; | |
| viewportContainer!.scale.x = initialScale; | |
| viewportContainer!.scale.y = initialScale; | |
| viewportContainer!.x = initialPosition.x; | |
| viewportContainer!.y = initialPosition.y; | |
| } | |
| async function renderAnnotations() { | |
| if (!app || !value || !viewportContainer) return; | |
| viewportContainer!.removeChildren(); | |
| polygonGraphics.clear(); | |
| polygonTexts.forEach((text) => text.destroy()); | |
| polygonTexts.clear(); | |
| textContainer = new Container(); | |
| textContainer.zIndex = 1000; | |
| viewportContainer!.sortableChildren = true; | |
| viewportContainer!.addChild(textContainer); | |
| if (value.image) { | |
| let imageUrl = ""; | |
| if (typeof value.image === "string") { | |
| imageUrl = value.image; | |
| } else if (value.image.url) { | |
| imageUrl = value.image.url; | |
| } else if (value.image.path) { | |
| if (root && !value.image.path.startsWith("http")) { | |
| imageUrl = `${root}/file=${value.image.path}`; | |
| } else { | |
| imageUrl = value.image.path; | |
| } | |
| } | |
| if (imageUrl) { | |
| try { | |
| const img = new Image(); | |
| img.crossOrigin = "anonymous"; | |
| const imageLoadPromise = new Promise<HTMLImageElement>( | |
| (resolve, reject) => { | |
| img.onload = () => resolve(img); | |
| img.onerror = reject; | |
| img.src = imageUrl; | |
| }, | |
| ); | |
| const loadedImage = await imageLoadPromise; | |
| const texture = Texture.from(loadedImage); | |
| imageSprite = new Sprite(texture); | |
| const scaleX = app.screen.width / texture.width; | |
| const scaleY = app.screen.height / texture.height; | |
| const scale = Math.min(scaleX, scaleY); | |
| imageSprite.scale.set(scale); | |
| const displayWidth = texture.width * scale; | |
| const displayHeight = texture.height * scale; | |
| imageSprite.x = (app.screen.width - displayWidth) / 2; | |
| imageSprite.y = (app.screen.height - displayHeight) / 2; | |
| imageRect = { | |
| left: imageSprite.x, | |
| top: imageSprite.y, | |
| right: imageSprite.x + displayWidth, | |
| bottom: imageSprite.y + displayHeight, | |
| width: displayWidth, | |
| height: displayHeight, | |
| naturalWidth: texture.width, | |
| naturalHeight: texture.height, | |
| }; | |
| viewportContainer!.addChild(imageSprite); | |
| initialScale = 1; | |
| initialPosition.x = 0; | |
| initialPosition.y = 0; | |
| viewportContainer!.scale.x = 1; | |
| viewportContainer!.scale.y = 1; | |
| viewportContainer!.x = 0; | |
| viewportContainer!.y = 0; | |
| } catch (error) { | |
| return; | |
| } | |
| } | |
| } | |
| if (value.polygons && value.polygons.length > 0 && imageSprite) { | |
| value.polygons.forEach((polygon) => { | |
| const graphics = new Graphics(); | |
| let color = 0xff0000; | |
| try { | |
| if (polygon.color) { | |
| const colorStr = polygon.color.replace("#", ""); | |
| color = parseInt(colorStr, 16); | |
| } | |
| } catch (e) { | |
| color = 0xff0000; | |
| } | |
| const polygonMaskOpacity = polygon.mask_opacity ?? 0.2; | |
| const selectedMaskAlpha = polygon.selected_mask_opacity ?? 0.5; | |
| const polygonStrokeOpacity = polygon.stroke_opacity ?? 0.6; | |
| const selectedStrokeAlpha = | |
| polygon.selected_stroke_opacity ?? 1.0; | |
| const strokeWidth = polygon.stroke_width ?? 0.7; | |
| const initialMaskAlpha = selectedPolygonIds.includes(polygon.id) | |
| ? selectedMaskAlpha | |
| : polygonMaskOpacity; | |
| const initialStrokeAlpha = selectedPolygonIds.includes( | |
| polygon.id, | |
| ) | |
| ? selectedStrokeAlpha | |
| : polygonStrokeOpacity; | |
| if (polygon.coordinates && polygon.coordinates.length > 0) { | |
| drawPolygonPath( | |
| graphics, | |
| polygon, | |
| initialMaskAlpha, | |
| strokeWidth, | |
| initialStrokeAlpha, | |
| ); | |
| } | |
| graphics.eventMode = "static"; | |
| graphics.cursor = "pointer"; | |
| const originalMaskAlpha = polygonMaskOpacity; | |
| const hoverMaskAlpha = Math.min(polygonMaskOpacity + 0.1, 1.0); | |
| const hoverStrokeAlpha = Math.min( | |
| polygonStrokeOpacity + 0.2, | |
| 1.0, | |
| ); | |
| graphics.on("pointerover", () => { | |
| if (!selectedPolygonIds.includes(polygon.id)) { | |
| graphics.clear(); | |
| drawPolygonPath( | |
| graphics, | |
| polygon, | |
| hoverMaskAlpha, | |
| strokeWidth, | |
| hoverStrokeAlpha, | |
| ); | |
| } | |
| }); | |
| graphics.on("pointerout", () => { | |
| if (!selectedPolygonIds.includes(polygon.id)) { | |
| graphics.clear(); | |
| drawPolygonPath( | |
| graphics, | |
| polygon, | |
| originalMaskAlpha, | |
| strokeWidth, | |
| polygonStrokeOpacity, | |
| ); | |
| } | |
| }); | |
| graphics.on("pointerdown", (event) => { | |
| handlePolygonSelection(polygon.id, event); | |
| }); | |
| viewportContainer!.addChild(graphics); | |
| polygonGraphics.set(polygon.id, graphics); | |
| if (polygon.display_text && polygon.display_font_size && polygon.display_font_size > 0) { | |
| const text = createPolygonText(polygon); | |
| const center = calculatePolygonCenter(polygon.coordinates); | |
| text.anchor.set(0.5, 0.5); | |
| text.x = center.x; | |
| text.y = center.y; | |
| text.eventMode = "static"; | |
| text.cursor = "pointer"; | |
| text.on("pointerdown", (event) => { | |
| handlePolygonSelection(polygon.id, event); | |
| }); | |
| textContainer!.addChild(text); | |
| polygonTexts.set(polygon.id, text); | |
| } | |
| }); | |
| } | |
| } | |
| function createPolygonText(polygon: any): Text { | |
| const style = new TextStyle({ | |
| fontSize: polygon.display_font_size || 14, | |
| fill: polygon.display_text_color || "#000000", | |
| fontWeight: "bold", | |
| align: "center" | |
| }); | |
| return new Text({ | |
| text: polygon.display_text, | |
| style: style | |
| }); | |
| } | |
| function calculatePolygonCenter(coordinates: number[][]): { x: number; y: number } { | |
| const displayCoords = coordinates.map((coord) => [ | |
| (coord[0] / (imageRect.naturalWidth - 1)) * imageRect.width + imageRect.left, | |
| (coord[1] / (imageRect.naturalHeight - 1)) * imageRect.height + imageRect.top, | |
| ]); | |
| let centerX = 0; | |
| let centerY = 0; | |
| displayCoords.forEach((coord) => { | |
| centerX += coord[0]; | |
| centerY += coord[1]; | |
| }); | |
| return { | |
| x: centerX / displayCoords.length, | |
| y: centerY / displayCoords.length | |
| }; | |
| } | |
| function drawPolygonPath( | |
| graphics: Graphics, | |
| polygon: any, | |
| maskAlpha: number = 0.2, | |
| strokeWidth: number = 0.7, | |
| strokeAlpha: number = 0.6, | |
| ) { | |
| if (polygon.coordinates && polygon.coordinates.length > 0) { | |
| const displayCoords = polygon.coordinates.map((coord: number[]) => { | |
| return [ | |
| (coord[0] / (imageRect.naturalWidth - 1)) * | |
| imageRect.width + | |
| imageRect.left, | |
| (coord[1] / (imageRect.naturalHeight - 1)) * | |
| imageRect.height + | |
| imageRect.top, | |
| ]; | |
| }); | |
| let color = 0xff0000; | |
| try { | |
| if (polygon.color) { | |
| const colorStr = polygon.color.replace("#", ""); | |
| color = parseInt(colorStr, 16); | |
| } | |
| } catch (e) { | |
| color = 0xff0000; | |
| } | |
| graphics.poly(displayCoords.flat()); | |
| graphics.fill({ color: color, alpha: maskAlpha }); | |
| graphics.stroke({ | |
| width: strokeWidth, | |
| color: color, | |
| alpha: strokeAlpha, | |
| }); | |
| } | |
| } | |
| onMount(async () => { | |
| await tick(); | |
| await initPixiApp(); | |
| if (value) { | |
| await renderAnnotations(); | |
| } | |
| }); | |
| onDestroy(() => { | |
| if (app) { | |
| app.canvas.removeEventListener("wheel", handleWheel); | |
| window.removeEventListener("keydown", handleKeydown); | |
| if (canvasContainer) { | |
| canvasContainer.removeEventListener("mouseenter", () => {}); | |
| canvasContainer.removeEventListener("mouseleave", () => {}); | |
| } | |
| app.destroy(true, { children: true, texture: true }); | |
| } | |
| }); | |
| async function handleResize() { | |
| if (!canvasContainer || !app) return; | |
| const newWidth = canvasContainer.clientWidth; | |
| const newHeight = canvasContainer.clientHeight; | |
| if (newWidth !== app.screen.width || newHeight !== app.screen.height) { | |
| app.renderer.resize(newWidth, newHeight); | |
| await renderAnnotations(); | |
| } | |
| } | |
| $: if (canvasContainer) { | |
| handleResize(); | |
| } | |
| $: (value, gradio.dispatch("change")); | |
| </script> | |
| <Block | |
| {visible} | |
| variant={"solid"} | |
| padding={false} | |
| {elem_id} | |
| {elem_classes} | |
| allow_overflow={false} | |
| {container} | |
| {scale} | |
| {min_width} | |
| {height} | |
| {width} | |
| > | |
| <StatusTracker | |
| autoscroll={gradio.autoscroll} | |
| i18n={gradio.i18n} | |
| {...loading_status} | |
| on:clear_status={() => gradio.dispatch("clear_status", loading_status)} | |
| /> | |
| <BlockLabel | |
| {show_label} | |
| Icon={ImageIcon} | |
| label={label || "Image Annotations"} | |
| /> | |
| <div class="container"> | |
| <div class="canvas-container" bind:this={canvasContainer} /> | |
| </div> | |
| </Block> | |
| <style> | |
| .container { | |
| display: flex; | |
| position: relative; | |
| flex-direction: column; | |
| justify-content: center; | |
| align-items: center; | |
| width: 100%; | |
| height: 100%; | |
| } | |
| .canvas-container { | |
| position: relative; | |
| width: 100%; | |
| height: 100%; | |
| min-height: 400px; | |
| overflow: hidden; | |
| display: flex; | |
| justify-content: center; | |
| align-items: center; | |
| background-color: #f0f0f0; | |
| } | |
| :global(.canvas-container canvas) { | |
| display: block; | |
| width: 100%; | |
| height: 100%; | |
| object-fit: contain; | |
| } | |
| </style> | |