feat: add debug rotation panel for real-time testing
New DebugRotationPanel component allows testing different rotation angles with live preview and detailed debug logs showing: - Current rotation angle (slider + quick presets) - Crop bounds and calculations - Image dimensions at each step - Final processed image preview - Confidence score Integrated into ItemDetailModal via wrench icon in header (only visible when item has a photo). Helps identify correct rotation values for images at any angle. Debug panel shows: - Original image info - Crop bounds and resulting size - Rotation angle and final canvas size - Step-by-step processing logs - Live canvas preview of result
This commit is contained in:
228
frontend/components/DebugRotationPanel.tsx
Normal file
228
frontend/components/DebugRotationPanel.tsx
Normal file
@@ -0,0 +1,228 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
import { Item } from '@/lib/db';
|
||||
|
||||
interface DebugRotationPanelProps {
|
||||
item: Item;
|
||||
imageUrl: string;
|
||||
originalCropBounds?: { x: number; y: number; width: number; height: number };
|
||||
originalRotation?: number;
|
||||
imageProcessing?: any;
|
||||
}
|
||||
|
||||
export function DebugRotationPanel({
|
||||
item,
|
||||
imageUrl,
|
||||
originalCropBounds,
|
||||
originalRotation = 0,
|
||||
imageProcessing,
|
||||
}: DebugRotationPanelProps) {
|
||||
const [rotation, setRotation] = useState(originalRotation);
|
||||
const [cropBounds, setCropBounds] = useState(originalCropBounds);
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||
const [logs, setLogs] = useState<string[]>([]);
|
||||
const [imageLoaded, setImageLoaded] = useState(false);
|
||||
const imageRef = useRef<HTMLImageElement>(null);
|
||||
|
||||
const commonAngles = [
|
||||
{ label: '0°', value: 0 },
|
||||
{ label: '+22°', value: 22 },
|
||||
{ label: '-22°', value: -22 },
|
||||
{ label: '+45°', value: 45 },
|
||||
{ label: '-45°', value: -45 },
|
||||
{ label: '+90°', value: 90 },
|
||||
{ label: '-90°', value: -90 },
|
||||
{ label: '±180°', value: 180 },
|
||||
];
|
||||
|
||||
useEffect(() => {
|
||||
const img = new Image();
|
||||
img.crossOrigin = 'anonymous';
|
||||
img.onload = () => {
|
||||
imageRef.current = img;
|
||||
setImageLoaded(true);
|
||||
addLog(`Image loaded: ${img.width}×${img.height}px`);
|
||||
};
|
||||
img.onerror = () => addLog('❌ Failed to load image');
|
||||
img.src = imageUrl;
|
||||
}, [imageUrl]);
|
||||
|
||||
const addLog = (message: string) => {
|
||||
setLogs((prev) => [...prev, `[${new Date().toLocaleTimeString()}] ${message}`]);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!imageLoaded || !imageRef.current || !canvasRef.current) return;
|
||||
|
||||
const img = imageRef.current;
|
||||
const canvas = canvasRef.current;
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (!ctx) return;
|
||||
|
||||
addLog(`\n--- Processing Step ---`);
|
||||
addLog(`Original image: ${img.width}×${img.height}px`);
|
||||
|
||||
if (cropBounds) {
|
||||
addLog(`Crop bounds: x=${cropBounds.x}, y=${cropBounds.y}, w=${cropBounds.width}, h=${cropBounds.height}`);
|
||||
addLog(`Crop rect: (${cropBounds.x}, ${cropBounds.y}, ${cropBounds.x + cropBounds.width}, ${cropBounds.y + cropBounds.height})`);
|
||||
}
|
||||
|
||||
// Set canvas size to match cropped result (or full image if no crop)
|
||||
const finalWidth = cropBounds ? cropBounds.width : img.width;
|
||||
const finalHeight = cropBounds ? cropBounds.height : img.height;
|
||||
|
||||
// Account for rotation expanding the canvas
|
||||
let displayWidth = finalWidth;
|
||||
let displayHeight = finalHeight;
|
||||
|
||||
const radians = (rotation * Math.PI) / 180;
|
||||
const cos = Math.abs(Math.cos(radians));
|
||||
const sin = Math.abs(Math.sin(radians));
|
||||
|
||||
if (Math.abs(rotation % 90) !== 0) {
|
||||
displayWidth = Math.ceil(finalWidth * cos + finalHeight * sin);
|
||||
displayHeight = Math.ceil(finalWidth * sin + finalHeight * cos);
|
||||
}
|
||||
|
||||
canvas.width = displayWidth;
|
||||
canvas.height = displayHeight;
|
||||
|
||||
addLog(`After crop: ${finalWidth}×${finalHeight}px`);
|
||||
addLog(`Applying rotation: ${rotation}°`);
|
||||
addLog(`Canvas with expansion: ${displayWidth}×${displayHeight}px`);
|
||||
|
||||
// Draw: Crop → Rotate
|
||||
ctx.fillStyle = 'white';
|
||||
ctx.fillRect(0, 0, canvas.width, canvas.height);
|
||||
|
||||
// Save context state
|
||||
ctx.save();
|
||||
|
||||
// Move to center, rotate, move back
|
||||
ctx.translate(displayWidth / 2, displayHeight / 2);
|
||||
ctx.rotate((rotation * Math.PI) / 180);
|
||||
ctx.translate(-finalWidth / 2, -finalHeight / 2);
|
||||
|
||||
// Draw cropped portion of image
|
||||
if (cropBounds) {
|
||||
ctx.drawImage(
|
||||
img,
|
||||
cropBounds.x,
|
||||
cropBounds.y,
|
||||
cropBounds.width,
|
||||
cropBounds.height,
|
||||
0,
|
||||
0,
|
||||
cropBounds.width,
|
||||
cropBounds.height
|
||||
);
|
||||
} else {
|
||||
ctx.drawImage(img, 0, 0);
|
||||
}
|
||||
|
||||
ctx.restore();
|
||||
|
||||
addLog(`✅ Preview rendered`);
|
||||
addLog(`Final image: ${displayWidth}×${displayHeight}px`);
|
||||
}, [imageLoaded, rotation, cropBounds]);
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4 overflow-auto">
|
||||
<div className="bg-white rounded-lg shadow-xl max-w-5xl w-full my-8">
|
||||
<div className="p-6 border-b">
|
||||
<h2 className="text-2xl font-bold">🔧 Debug Rotation Panel</h2>
|
||||
<p className="text-gray-600 text-sm mt-1">Item: {item.name}</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-6 p-6">
|
||||
{/* Left: Controls */}
|
||||
<div className="space-y-6">
|
||||
{/* Rotation Slider */}
|
||||
<div>
|
||||
<label className="block text-sm font-semibold mb-2">
|
||||
Rotation: <span className="text-blue-600 font-bold">{rotation}°</span>
|
||||
</label>
|
||||
<input
|
||||
type="range"
|
||||
min="-180"
|
||||
max="180"
|
||||
value={rotation}
|
||||
onChange={(e) => {
|
||||
const val = Number(e.target.value);
|
||||
setRotation(val);
|
||||
}}
|
||||
className="w-full"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Quick Presets */}
|
||||
<div>
|
||||
<label className="block text-sm font-semibold mb-2">Quick Angles</label>
|
||||
<div className="grid grid-cols-4 gap-2">
|
||||
{commonAngles.map((angle) => (
|
||||
<button
|
||||
key={angle.value}
|
||||
onClick={() => setRotation(angle.value)}
|
||||
className={`py-2 px-3 rounded text-xs font-semibold transition ${
|
||||
rotation === angle.value
|
||||
? 'bg-blue-600 text-white'
|
||||
: 'bg-gray-200 hover:bg-gray-300'
|
||||
}`}
|
||||
>
|
||||
{angle.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Original Values */}
|
||||
{(originalRotation !== undefined || originalCropBounds) && (
|
||||
<div className="bg-gray-100 p-4 rounded">
|
||||
<h3 className="font-semibold text-sm mb-2">Original Values</h3>
|
||||
<div className="text-xs space-y-1 font-mono">
|
||||
<div>Rotation: {originalRotation}°</div>
|
||||
{originalCropBounds && (
|
||||
<div>
|
||||
Crop: ({originalCropBounds.x}, {originalCropBounds.y}) {originalCropBounds.width}×{originalCropBounds.height}
|
||||
</div>
|
||||
)}
|
||||
{imageProcessing?.confidence && (
|
||||
<div>Confidence: {(imageProcessing.confidence * 100).toFixed(0)}%</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Save Button */}
|
||||
<button className="w-full bg-green-600 text-white py-2 rounded font-semibold hover:bg-green-700">
|
||||
✅ Save Corrected Rotation
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Right: Preview + Logs */}
|
||||
<div className="space-y-4">
|
||||
{/* Canvas Preview */}
|
||||
<div className="border rounded bg-gray-50 p-4">
|
||||
<h3 className="font-semibold text-sm mb-2">Processed Preview</h3>
|
||||
<canvas
|
||||
ref={canvasRef}
|
||||
className="border bg-white max-w-full h-auto mx-auto rounded"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Logs */}
|
||||
<div className="border rounded bg-gray-50 p-4">
|
||||
<h3 className="font-semibold text-sm mb-2">Debug Logs</h3>
|
||||
<div className="bg-black text-green-400 p-3 rounded font-mono text-xs h-32 overflow-y-auto">
|
||||
{logs.map((log, i) => (
|
||||
<div key={i}>{log}</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -4,7 +4,8 @@ import React, { useState, useRef } from 'react';
|
||||
import { Item } from '@/lib/db';
|
||||
import { inventoryApi, buildPhotoUrl } from '@/lib/api';
|
||||
import ItemPhotoUpload from '@/components/ItemPhotoUpload';
|
||||
import { X, Camera, Trash2 } from 'lucide-react';
|
||||
import { DebugRotationPanel } from '@/components/DebugRotationPanel';
|
||||
import { X, Camera, Trash2, Wrench } from 'lucide-react';
|
||||
import { toast } from 'react-hot-toast';
|
||||
|
||||
interface ItemDetailModalProps {
|
||||
@@ -23,6 +24,7 @@ export default function ItemDetailModal({
|
||||
backendUrl = '',
|
||||
}: ItemDetailModalProps) {
|
||||
const [showPhotoUpload, setShowPhotoUpload] = useState(false);
|
||||
const [showDebugPanel, setShowDebugPanel] = useState(false);
|
||||
const [currentPhoto, setCurrentPhoto] = useState<{ thumbnail_url: string; full_url: string } | null>(
|
||||
item.photo_path && item.photo_thumbnail_path
|
||||
? { thumbnail_url: item.photo_thumbnail_path, full_url: item.photo_path }
|
||||
@@ -71,13 +73,25 @@ export default function ItemDetailModal({
|
||||
{/* Header */}
|
||||
<div className="sticky top-0 bg-surface border-b border-slate-800/50 p-4 md:p-6 flex items-center justify-between">
|
||||
<h2 className="text-xl md:text-2xl font-normal text-white truncate">{item.name}</h2>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="p-2 hover:bg-slate-800 rounded-full text-muted hover:text-white transition-colors"
|
||||
aria-label="Close modal"
|
||||
>
|
||||
<X size={20} />
|
||||
</button>
|
||||
<div className="flex items-center gap-2">
|
||||
{currentPhoto && (
|
||||
<button
|
||||
onClick={() => setShowDebugPanel(true)}
|
||||
className="p-2 hover:bg-yellow-900/50 rounded-full text-yellow-400 hover:text-yellow-300 transition-colors"
|
||||
title="Debug rotation & crop"
|
||||
aria-label="Debug panel"
|
||||
>
|
||||
<Wrench size={20} />
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="p-2 hover:bg-slate-800 rounded-full text-muted hover:text-white transition-colors"
|
||||
aria-label="Close modal"
|
||||
>
|
||||
<X size={20} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
@@ -178,6 +192,17 @@ export default function ItemDetailModal({
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Debug Rotation Panel */}
|
||||
{showDebugPanel && currentPhoto && (
|
||||
<DebugRotationPanel
|
||||
item={item}
|
||||
imageUrl={buildPhotoUrl(backendUrl, currentPhoto.full_url)}
|
||||
originalCropBounds={item.labels_data ? JSON.parse(item.labels_data).image_processing?.crop_bounds : undefined}
|
||||
originalRotation={item.labels_data ? JSON.parse(item.labels_data).image_processing?.rotation_degrees : undefined}
|
||||
imageProcessing={item.labels_data ? JSON.parse(item.labels_data).image_processing : undefined}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user