Merge branch 'main' into claude/add-eye-detection-feature-69XOl
This commit is contained in:
+54
-3
@@ -26,6 +26,9 @@ function App() {
|
||||
const [layers, setLayers] = useState([]);
|
||||
const [activeLayer, setActiveLayer] = useState('background');
|
||||
const [generatedMask, setGeneratedMask] = useState(null);
|
||||
const [advancedToolMode, setAdvancedToolMode] = useState(null); // 'smart-select', 'color-select', 'object-remove'
|
||||
const [canvasZoom, setCanvasZoom] = useState(1);
|
||||
const [externalSelection, setExternalSelection] = useState(null); // For smart-select/color-select polygon results
|
||||
const editsRef = useRef([]);
|
||||
|
||||
// Create project and upload image
|
||||
@@ -284,9 +287,49 @@ function App() {
|
||||
};
|
||||
|
||||
// Handle mask generation from smart select / color select
|
||||
const handleMaskGenerated = async (maskBlob, source) => {
|
||||
setGeneratedMask({ blob: maskBlob, source });
|
||||
// The mask can be used for various operations
|
||||
const handleMaskGenerated = async (maskData, source) => {
|
||||
setGeneratedMask({ data: maskData, source });
|
||||
// Convert mask to selection if it contains polygon data
|
||||
if (maskData && maskData.polygon && maskData.polygon.length > 0) {
|
||||
// Set external selection for canvas to draw
|
||||
setExternalSelection({
|
||||
polygon: maskData.polygon,
|
||||
bbox: maskData.bbox,
|
||||
});
|
||||
// Also set selection state for fix button
|
||||
setSelection({
|
||||
type: 'polygon',
|
||||
bbox: maskData.bbox,
|
||||
selectionData: { points: maskData.polygon },
|
||||
});
|
||||
}
|
||||
// Reset tool mode after selection
|
||||
setAdvancedToolMode(null);
|
||||
};
|
||||
|
||||
// Handle canvas click for advanced tools (smart select, color select)
|
||||
const handleAdvancedToolClick = async (x, y, color) => {
|
||||
if (!project || !advancedToolMode) return;
|
||||
|
||||
try {
|
||||
setIsProcessing(true);
|
||||
setError(null);
|
||||
|
||||
if (advancedToolMode === 'smart-select') {
|
||||
const result = await toolsApi.smartSelect(project.id, x, y);
|
||||
handleMaskGenerated(result, 'smart-select');
|
||||
} else if (advancedToolMode === 'color-select') {
|
||||
// Color is passed from canvas click
|
||||
if (color) {
|
||||
const result = await toolsApi.colorSelect(project.id, color.r, color.g, color.b, 30);
|
||||
handleMaskGenerated(result, 'color-select');
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
setError(`${advancedToolMode} failed: ${err.message}`);
|
||||
} finally {
|
||||
setIsProcessing(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Handle flatten layers
|
||||
@@ -361,6 +404,11 @@ function App() {
|
||||
imageUrl={currentImageUrl}
|
||||
onSelectionChange={setSelection}
|
||||
selectionMode={selectionMode}
|
||||
advancedToolMode={advancedToolMode}
|
||||
onAdvancedToolClick={handleAdvancedToolClick}
|
||||
zoom={canvasZoom}
|
||||
onZoomChange={setCanvasZoom}
|
||||
externalSelection={externalSelection}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -391,6 +439,8 @@ function App() {
|
||||
isProcessing={isProcessing}
|
||||
setIsProcessing={setIsProcessing}
|
||||
setError={setError}
|
||||
activeToolMode={advancedToolMode}
|
||||
setActiveToolMode={setAdvancedToolMode}
|
||||
/>
|
||||
|
||||
<Layers
|
||||
@@ -401,6 +451,7 @@ function App() {
|
||||
setActiveLayer={setActiveLayer}
|
||||
onFlatten={handleFlattenLayers}
|
||||
isProcessing={isProcessing}
|
||||
onError={setError}
|
||||
/>
|
||||
|
||||
<EyeCatalog
|
||||
|
||||
@@ -11,8 +11,9 @@ const AdvancedTools = ({
|
||||
isProcessing,
|
||||
setIsProcessing,
|
||||
setError,
|
||||
activeToolMode,
|
||||
setActiveToolMode,
|
||||
}) => {
|
||||
const [activeToolMode, setActiveToolMode] = useState(null);
|
||||
const [colorTolerance, setColorTolerance] = useState(30);
|
||||
|
||||
const handleRemoveBackground = async () => {
|
||||
|
||||
@@ -43,3 +43,66 @@
|
||||
z-index: 10;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* Zoom controls */
|
||||
.zoom-controls {
|
||||
position: absolute;
|
||||
bottom: 10px;
|
||||
right: 10px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
background-color: rgba(0, 0, 0, 0.8);
|
||||
padding: 6px 10px;
|
||||
border-radius: 4px;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.zoom-controls button {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
padding: 0;
|
||||
font-size: 18px;
|
||||
font-weight: bold;
|
||||
background-color: #444;
|
||||
color: white;
|
||||
border: 1px solid #666;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.zoom-controls button:hover {
|
||||
background-color: #555;
|
||||
}
|
||||
|
||||
.zoom-level {
|
||||
color: white;
|
||||
font-size: 12px;
|
||||
min-width: 45px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
/* Tool mode indicator */
|
||||
.tool-mode-indicator {
|
||||
position: absolute;
|
||||
top: 10px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
background-color: rgba(0, 120, 255, 0.9);
|
||||
color: white;
|
||||
padding: 10px 20px;
|
||||
border-radius: 4px;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
z-index: 10;
|
||||
white-space: nowrap;
|
||||
animation: pulse 2s infinite;
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.7; }
|
||||
}
|
||||
|
||||
@@ -2,12 +2,22 @@ import React, { useEffect, useRef, useState, useCallback } from 'react';
|
||||
import { fabric } from 'fabric';
|
||||
import './ImageCanvas.css';
|
||||
|
||||
const ImageCanvas = ({ imageUrl, onSelectionChange, selectionMode }) => {
|
||||
const ImageCanvas = ({
|
||||
imageUrl,
|
||||
onSelectionChange,
|
||||
selectionMode,
|
||||
advancedToolMode,
|
||||
onAdvancedToolClick,
|
||||
zoom = 1,
|
||||
onZoomChange,
|
||||
externalSelection, // { polygon: [[x,y],...], bbox: {x,y,width,height} }
|
||||
}) => {
|
||||
const canvasRef = useRef(null);
|
||||
const fabricCanvasRef = useRef(null);
|
||||
const [currentSelection, setCurrentSelection] = useState(null);
|
||||
const [isDrawing, setIsDrawing] = useState(false);
|
||||
const [isTransformMode, setIsTransformMode] = useState(false);
|
||||
const [currentZoom, setCurrentZoom] = useState(zoom);
|
||||
const lassoPoints = useRef([]);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -50,11 +60,36 @@ const ImageCanvas = ({ imageUrl, onSelectionChange, selectionMode }) => {
|
||||
handleResize();
|
||||
window.addEventListener('resize', handleResize);
|
||||
|
||||
// Mouse wheel zoom
|
||||
const handleWheel = (opt) => {
|
||||
const e = opt.e;
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
const delta = e.deltaY;
|
||||
let newZoom = canvas.getZoom();
|
||||
newZoom *= 0.999 ** delta;
|
||||
|
||||
// Clamp zoom between 0.1x and 10x
|
||||
if (newZoom > 10) newZoom = 10;
|
||||
if (newZoom < 0.1) newZoom = 0.1;
|
||||
|
||||
// Zoom to point under cursor
|
||||
const pointer = canvas.getPointer(e, true);
|
||||
canvas.zoomToPoint({ x: pointer.x, y: pointer.y }, newZoom);
|
||||
|
||||
setCurrentZoom(newZoom);
|
||||
onZoomChange?.(newZoom);
|
||||
};
|
||||
|
||||
canvas.on('mouse:wheel', handleWheel);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('resize', handleResize);
|
||||
canvas.off('mouse:wheel', handleWheel);
|
||||
canvas.dispose();
|
||||
};
|
||||
}, []);
|
||||
}, [onZoomChange]);
|
||||
|
||||
// Load image when URL changes
|
||||
useEffect(() => {
|
||||
@@ -94,12 +129,128 @@ const ImageCanvas = ({ imageUrl, onSelectionChange, selectionMode }) => {
|
||||
}, { crossOrigin: 'anonymous' });
|
||||
}, [imageUrl]);
|
||||
|
||||
// Handle advanced tool mode clicks (smart-select, color-select)
|
||||
useEffect(() => {
|
||||
if (!fabricCanvasRef.current || !advancedToolMode) return;
|
||||
|
||||
const canvas = fabricCanvasRef.current;
|
||||
const bgImage = canvas.backgroundImage;
|
||||
|
||||
const handleAdvancedClick = async (e) => {
|
||||
if (!bgImage || !onAdvancedToolClick) return;
|
||||
|
||||
const pointer = canvas.getPointer(e.e);
|
||||
|
||||
// Convert canvas coordinates to image coordinates
|
||||
const imgScale = bgImage.scaleX;
|
||||
const imgLeft = bgImage.left;
|
||||
const imgTop = bgImage.top;
|
||||
|
||||
const imgX = Math.round((pointer.x - imgLeft) / imgScale);
|
||||
const imgY = Math.round((pointer.y - imgTop) / imgScale);
|
||||
|
||||
// Check if click is within image bounds
|
||||
if (imgX < 0 || imgY < 0 || imgX > bgImage.width || imgY > bgImage.height) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (advancedToolMode === 'color-select') {
|
||||
// Get pixel color at click position
|
||||
const ctx = canvas.getContext('2d');
|
||||
const canvasX = pointer.x * canvas.getZoom();
|
||||
const canvasY = pointer.y * canvas.getZoom();
|
||||
|
||||
// For color picking, we need to get the color from the image
|
||||
// Create a temporary canvas to read pixel color
|
||||
const tempCanvas = document.createElement('canvas');
|
||||
tempCanvas.width = bgImage.width;
|
||||
tempCanvas.height = bgImage.height;
|
||||
const tempCtx = tempCanvas.getContext('2d');
|
||||
|
||||
// Draw the image element to temp canvas
|
||||
const imgElement = bgImage.getElement();
|
||||
tempCtx.drawImage(imgElement, 0, 0);
|
||||
|
||||
const pixelData = tempCtx.getImageData(imgX, imgY, 1, 1).data;
|
||||
const color = { r: pixelData[0], g: pixelData[1], b: pixelData[2] };
|
||||
|
||||
onAdvancedToolClick(imgX, imgY, color);
|
||||
} else {
|
||||
onAdvancedToolClick(imgX, imgY, null);
|
||||
}
|
||||
};
|
||||
|
||||
canvas.on('mouse:down', handleAdvancedClick);
|
||||
|
||||
return () => {
|
||||
canvas.off('mouse:down', handleAdvancedClick);
|
||||
};
|
||||
}, [advancedToolMode, onAdvancedToolClick]);
|
||||
|
||||
// Handle external selection (from smart-select or color-select)
|
||||
useEffect(() => {
|
||||
if (!fabricCanvasRef.current || !externalSelection?.polygon?.length) return;
|
||||
|
||||
const canvas = fabricCanvasRef.current;
|
||||
const bgImage = canvas.backgroundImage;
|
||||
|
||||
if (!bgImage) return;
|
||||
|
||||
// Clear previous selection
|
||||
if (currentSelection) {
|
||||
canvas.remove(currentSelection);
|
||||
}
|
||||
|
||||
// Convert image coordinates to canvas coordinates
|
||||
const imgScale = bgImage.scaleX;
|
||||
const imgLeft = bgImage.left;
|
||||
const imgTop = bgImage.top;
|
||||
|
||||
const canvasPoints = externalSelection.polygon.map(([x, y]) => ({
|
||||
x: x * imgScale + imgLeft,
|
||||
y: y * imgScale + imgTop,
|
||||
}));
|
||||
|
||||
// Create polygon selection
|
||||
const polygon = new fabric.Polygon(canvasPoints, {
|
||||
fill: 'rgba(255, 255, 255, 0.3)',
|
||||
stroke: '#00ff00',
|
||||
strokeWidth: 2,
|
||||
selectable: true,
|
||||
hasControls: true,
|
||||
hasBorders: true,
|
||||
lockRotation: false,
|
||||
cornerColor: '#00ff00',
|
||||
cornerSize: 10,
|
||||
transparentCorners: false,
|
||||
borderColor: '#00ff00',
|
||||
borderScaleFactor: 2,
|
||||
});
|
||||
|
||||
canvas.add(polygon);
|
||||
canvas.setActiveObject(polygon);
|
||||
setCurrentSelection(polygon);
|
||||
lassoPoints.current = canvasPoints;
|
||||
|
||||
// Notify parent of selection
|
||||
onSelectionChange({
|
||||
type: 'polygon',
|
||||
bbox: externalSelection.bbox,
|
||||
selectionData: { points: externalSelection.polygon },
|
||||
});
|
||||
|
||||
canvas.renderAll();
|
||||
}, [externalSelection]);
|
||||
|
||||
// Handle selection mode changes
|
||||
useEffect(() => {
|
||||
if (!fabricCanvasRef.current) return;
|
||||
|
||||
const canvas = fabricCanvasRef.current;
|
||||
|
||||
// Don't set up selection handlers if in advanced tool mode
|
||||
if (advancedToolMode) return;
|
||||
|
||||
// Clear previous selection when changing modes
|
||||
if (currentSelection) {
|
||||
canvas.remove(currentSelection);
|
||||
@@ -123,7 +274,7 @@ const ImageCanvas = ({ imageUrl, onSelectionChange, selectionMode }) => {
|
||||
} else if (selectionMode === 'lasso') {
|
||||
setupLassoMode(canvas);
|
||||
}
|
||||
}, [selectionMode]);
|
||||
}, [selectionMode, advancedToolMode]);
|
||||
|
||||
const setupRectangleMode = (canvas) => {
|
||||
let rect, isDown, startX, startY;
|
||||
@@ -497,10 +648,57 @@ const ImageCanvas = ({ imageUrl, onSelectionChange, selectionMode }) => {
|
||||
}
|
||||
};
|
||||
|
||||
const handleZoomIn = () => {
|
||||
if (!fabricCanvasRef.current) return;
|
||||
const canvas = fabricCanvasRef.current;
|
||||
let newZoom = canvas.getZoom() * 1.2;
|
||||
if (newZoom > 10) newZoom = 10;
|
||||
canvas.setZoom(newZoom);
|
||||
setCurrentZoom(newZoom);
|
||||
onZoomChange?.(newZoom);
|
||||
};
|
||||
|
||||
const handleZoomOut = () => {
|
||||
if (!fabricCanvasRef.current) return;
|
||||
const canvas = fabricCanvasRef.current;
|
||||
let newZoom = canvas.getZoom() / 1.2;
|
||||
if (newZoom < 0.1) newZoom = 0.1;
|
||||
canvas.setZoom(newZoom);
|
||||
setCurrentZoom(newZoom);
|
||||
onZoomChange?.(newZoom);
|
||||
};
|
||||
|
||||
const handleZoomReset = () => {
|
||||
if (!fabricCanvasRef.current) return;
|
||||
const canvas = fabricCanvasRef.current;
|
||||
canvas.setZoom(1);
|
||||
canvas.setViewportTransform([1, 0, 0, 1, 0, 0]);
|
||||
setCurrentZoom(1);
|
||||
onZoomChange?.(1);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="canvas-container">
|
||||
<canvas ref={canvasRef} />
|
||||
{currentSelection && (
|
||||
|
||||
{/* Zoom controls */}
|
||||
<div className="zoom-controls">
|
||||
<button onClick={handleZoomOut} title="Zoom Out">−</button>
|
||||
<span className="zoom-level">{Math.round(currentZoom * 100)}%</span>
|
||||
<button onClick={handleZoomIn} title="Zoom In">+</button>
|
||||
<button onClick={handleZoomReset} title="Reset Zoom">⟲</button>
|
||||
</div>
|
||||
|
||||
{/* Advanced tool mode indicator */}
|
||||
{advancedToolMode && (
|
||||
<div className="tool-mode-indicator">
|
||||
{advancedToolMode === 'smart-select' && 'Click on an object to select it'}
|
||||
{advancedToolMode === 'color-select' && 'Click on a color to select similar pixels'}
|
||||
{advancedToolMode === 'object-remove' && 'Click on an object to remove it'}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{currentSelection && !advancedToolMode && (
|
||||
<>
|
||||
<div className="selection-hint">
|
||||
Click selection to move/resize/rotate
|
||||
|
||||
@@ -11,6 +11,7 @@ const Layers = ({
|
||||
onLayerVisibilityChange,
|
||||
onFlatten,
|
||||
isProcessing,
|
||||
onError,
|
||||
}) => {
|
||||
const [draggedLayer, setDraggedLayer] = useState(null);
|
||||
|
||||
@@ -69,6 +70,56 @@ const Layers = ({
|
||||
loadLayers();
|
||||
};
|
||||
|
||||
const handleNewLayer = async () => {
|
||||
if (!projectId) return;
|
||||
try {
|
||||
// Create a new empty transparent layer
|
||||
const newLayer = {
|
||||
id: `layer-${Date.now()}`,
|
||||
name: `Layer ${layers.length + 1}`,
|
||||
visible: true,
|
||||
thumbnail: null,
|
||||
};
|
||||
setLayers([...layers, newLayer]);
|
||||
setActiveLayer(newLayer.id);
|
||||
} catch (err) {
|
||||
onError?.(`Failed to create layer: ${err.message}`);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteLayer = async () => {
|
||||
if (!projectId || activeLayer === 'background') return;
|
||||
try {
|
||||
const updatedLayers = layers.filter((l) => l.id !== activeLayer);
|
||||
setLayers(updatedLayers);
|
||||
setActiveLayer(updatedLayers.length > 0 ? updatedLayers[updatedLayers.length - 1].id : 'background');
|
||||
} catch (err) {
|
||||
onError?.(`Failed to delete layer: ${err.message}`);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDuplicateLayer = async () => {
|
||||
if (!projectId || activeLayer === 'background') return;
|
||||
try {
|
||||
const layerToDuplicate = layers.find((l) => l.id === activeLayer);
|
||||
if (!layerToDuplicate) return;
|
||||
|
||||
const newLayer = {
|
||||
...layerToDuplicate,
|
||||
id: `layer-${Date.now()}`,
|
||||
name: `${layerToDuplicate.name} copy`,
|
||||
};
|
||||
|
||||
const activeIndex = layers.findIndex((l) => l.id === activeLayer);
|
||||
const updatedLayers = [...layers];
|
||||
updatedLayers.splice(activeIndex + 1, 0, newLayer);
|
||||
setLayers(updatedLayers);
|
||||
setActiveLayer(newLayer.id);
|
||||
} catch (err) {
|
||||
onError?.(`Failed to duplicate layer: ${err.message}`);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="layers-panel">
|
||||
<div className="layers-header">
|
||||
@@ -142,6 +193,7 @@ const Layers = ({
|
||||
className="layer-action-btn"
|
||||
disabled={isProcessing || !projectId}
|
||||
title="Add new empty layer"
|
||||
onClick={handleNewLayer}
|
||||
>
|
||||
+ New Layer
|
||||
</button>
|
||||
@@ -149,6 +201,7 @@ const Layers = ({
|
||||
className="layer-action-btn"
|
||||
disabled={isProcessing || activeLayer === 'background'}
|
||||
title="Delete selected layer"
|
||||
onClick={handleDeleteLayer}
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
@@ -156,6 +209,7 @@ const Layers = ({
|
||||
className="layer-action-btn"
|
||||
disabled={isProcessing || activeLayer === 'background'}
|
||||
title="Duplicate selected layer"
|
||||
onClick={handleDuplicateLayer}
|
||||
>
|
||||
Duplicate
|
||||
</button>
|
||||
|
||||
@@ -171,20 +171,22 @@ export const toolsApi = {
|
||||
},
|
||||
|
||||
// Smart select object at point
|
||||
// Returns { polygon, bbox, mask_base64 }
|
||||
smartSelect: async (projectId, x, y) => {
|
||||
const formData = new FormData();
|
||||
formData.append('project_id', projectId);
|
||||
formData.append('point_x', x);
|
||||
formData.append('point_y', y);
|
||||
formData.append('return_format', 'json');
|
||||
|
||||
const response = await api.post('/tools/smart-select', formData, {
|
||||
headers: { 'Content-Type': 'multipart/form-data' },
|
||||
responseType: 'blob',
|
||||
});
|
||||
return response.data;
|
||||
},
|
||||
|
||||
// Select by color
|
||||
// Returns { polygon, bbox, mask_base64, color, tolerance }
|
||||
colorSelect: async (projectId, r, g, b, tolerance = 30) => {
|
||||
const formData = new FormData();
|
||||
formData.append('project_id', projectId);
|
||||
@@ -192,10 +194,10 @@ export const toolsApi = {
|
||||
formData.append('color_g', g);
|
||||
formData.append('color_b', b);
|
||||
formData.append('tolerance', tolerance);
|
||||
formData.append('return_format', 'json');
|
||||
|
||||
const response = await api.post('/tools/color-select', formData, {
|
||||
headers: { 'Content-Type': 'multipart/form-data' },
|
||||
responseType: 'blob',
|
||||
});
|
||||
return response.data;
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user