Merge branch 'main' into claude/add-eye-detection-feature-69XOl

This commit is contained in:
outis1one
2026-01-25 18:42:32 -05:00
committed by GitHub
11 changed files with 581 additions and 54 deletions
+54 -3
View File
@@ -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
+2 -1
View File
@@ -11,8 +11,9 @@ const AdvancedTools = ({
isProcessing,
setIsProcessing,
setError,
activeToolMode,
setActiveToolMode,
}) => {
const [activeToolMode, setActiveToolMode] = useState(null);
const [colorTolerance, setColorTolerance] = useState(30);
const handleRemoveBackground = async () => {
+63
View File
@@ -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; }
}
+202 -4
View File
@@ -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
+54
View File
@@ -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>
+4 -2
View File
@@ -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;
},