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:
2026-04-22 10:56:27 +03:00
parent f6d91c92b6
commit 1a774088a1
2 changed files with 261 additions and 8 deletions

View 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>
);
}

View File

@@ -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>
);
}