Skip to content

Commit e6ffbe9

Browse files
AmirMohammad CheraghaliAmirMohammad Cheraghali
authored andcommitted
fix: implement adaptive downscaling and robust IFD selection for TIFF support
1 parent e54c218 commit e6ffbe9

1 file changed

Lines changed: 127 additions & 60 deletions

File tree

src/components/dashboard/ImageWorkbenchNode.tsx

Lines changed: 127 additions & 60 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,8 @@ import {
88
Target,
99
Upload,
1010
AlertCircle,
11-
RefreshCw
11+
RefreshCw,
12+
Info
1213
} from 'lucide-react';
1314

1415
interface Annotation {
@@ -56,7 +57,7 @@ export const ImageWorkbenchNode: React.FC<NodeViewProps> = ({ node, updateAttrib
5657
return utifLoadPromise;
5758
};
5859

59-
// Convert TIFF ArrayBuffer to browser-readable PNG Data URL
60+
// Convert TIFF ArrayBuffer to browser-readable PNG/JPEG Data URL
6061
const processTiff = async (buffer: ArrayBuffer) => {
6162
console.log("[TIFF] Starting decoding, input size:", (buffer.byteLength / 1024).toFixed(1), "KB");
6263
await loadUTIF();
@@ -67,44 +68,78 @@ export const ImageWorkbenchNode: React.FC<NodeViewProps> = ({ node, updateAttrib
6768
console.log("[TIFF] IFDs found:", ifds.length);
6869

6970
if (!ifds || ifds.length === 0) {
70-
throw new Error("The TIFF file appears to be empty or corrupted.");
71+
throw new Error("The TIFF file appears to be empty or corrupted (no IFDs found).");
7172
}
7273

73-
// Find the first IFD with image dimensions (skip thumbnails/metadata if possible)
74-
let targetIFD = ifds.find((i: any) => i.width > 200 && i.height > 200);
75-
if (!targetIFD) targetIFD = ifds.find((i: any) => i.width && i.height);
76-
if (!targetIFD) targetIFD = ifds[0];
74+
// Find the "best" IFD. For scientific images, this is usually the one with the largest dimensions.
75+
// We also look for those with standard pixel types (tPixel).
76+
const sortedIFDs = [...ifds].sort((a: any, b: any) => (b.width * b.height) - (a.width * a.height));
77+
const targetIFD = sortedIFDs[0];
7778

7879
if (!targetIFD.width || !targetIFD.height) {
79-
console.error("[TIFF] Invalid target IFD:", targetIFD);
80-
throw new Error("Could not detect image dimensions in the TIFF structure.");
80+
console.error("[TIFF] Invalid IFD data:", targetIFD);
81+
throw new Error(`Invalid TIFF structure: Width=${targetIFD.width}, Height=${targetIFD.height}`);
8182
}
8283

83-
console.log("[TIFF] Decoding IFD:", { width: targetIFD.width, height: targetIFD.height, tPixel: targetIFD.tPixel });
84+
console.log("[TIFF] Target Image Dimensions:", targetIFD.width, "x", targetIFD.height);
8485
UTIF.decodeImage(buffer, targetIFD);
8586

86-
const rgba = UTIF.toRGBA8(targetIFD);
87+
let rgba = UTIF.toRGBA8(targetIFD);
8788
console.log("[TIFF] RGBA conversion complete, buffer length:", rgba.length);
88-
89+
90+
// --- ADAPTIVE DOWNSCALING ---
91+
// Browsers have canvas limits (often 4096px or 8192px).
92+
// Large microscopy images can hit these or cause memory overflows.
93+
const MAX_DIMENSION = 2048; // Safe threshold for notebook performance
94+
let scale = 1;
95+
if (targetIFD.width > MAX_DIMENSION || targetIFD.height > MAX_DIMENSION) {
96+
scale = Math.min(MAX_DIMENSION / targetIFD.width, MAX_DIMENSION / targetIFD.height);
97+
}
98+
8999
const canvas = document.createElement('canvas');
90-
canvas.width = targetIFD.width;
91-
canvas.height = targetIFD.height;
100+
const finalWidth = Math.floor(targetIFD.width * scale);
101+
const finalHeight = Math.floor(targetIFD.height * scale);
102+
103+
canvas.width = finalWidth;
104+
canvas.height = finalHeight;
92105
const ctx = canvas.getContext('2d');
93-
if (!ctx) throw new Error("Could not initialize conversion canvas.");
106+
if (!ctx) throw new Error("Could not initialize conversion canvas (likely browser memory limit).");
107+
108+
if (scale < 1) {
109+
console.log("[TIFF] Scaling down image:", scale.toFixed(2), "x ->", finalWidth, "x", finalHeight);
110+
// Temporarily put full image on a hidden canvas to use drawImage for high-quality scaling
111+
const tempCanvas = document.createElement('canvas');
112+
tempCanvas.width = targetIFD.width;
113+
tempCanvas.height = targetIFD.height;
114+
const tempCtx = tempCanvas.getContext('2d');
115+
if (!tempCtx) throw new Error("Could not initialize temporary scaling canvas.");
116+
117+
const imgData = tempCtx.createImageData(targetIFD.width, targetIFD.height);
118+
imgData.data.set(new Uint8ClampedArray(rgba));
119+
tempCtx.putImageData(imgData, 0, 0);
120+
121+
// Final draw with scaling
122+
ctx.imageSmoothingEnabled = true;
123+
ctx.imageSmoothingQuality = 'high';
124+
ctx.drawImage(tempCanvas, 0, 0, finalWidth, finalHeight);
125+
} else {
126+
const imgData = ctx.createImageData(canvas.width, canvas.height);
127+
imgData.data.set(new Uint8ClampedArray(rgba));
128+
ctx.putImageData(imgData, 0, 0);
129+
}
94130

95-
const imgData = ctx.createImageData(canvas.width, canvas.height);
96-
imgData.data.set(new Uint8ClampedArray(rgba));
97-
ctx.putImageData(imgData, 0, 0);
131+
// Cleanup large buffers immediately
132+
rgba = null;
98133

99-
// Use image/jpeg for large images to save space/bandwidth if over 4MB
100-
const type = (canvas.width * canvas.height > 2000000) ? 'image/jpeg' : 'image/png';
101-
const quality = type === 'image/jpeg' ? 0.92 : undefined;
134+
// Use image/jpeg for large images to save space (bloating data attributes breaks Tiptap/Supabase)
135+
const type = (finalWidth * finalHeight > 1000000) ? 'image/jpeg' : 'image/png';
136+
const quality = type === 'image/jpeg' ? 0.85 : undefined;
102137

103138
const dataUrl = canvas.toDataURL(type, quality);
104-
console.log("[TIFF] Data URL generated successfully, type:", type, "length:", (dataUrl.length / 1024).toFixed(1), "KB");
139+
console.log("[TIFF] Success, data URL type:", type, "length:", (dataUrl.length / 1024).toFixed(1), "KB");
105140
return dataUrl;
106141
} catch (error: unknown) {
107-
console.error("[TIFF] Error during processing:", error);
142+
console.error("[TIFF] Critical Processing Error:", error);
108143
throw error;
109144
}
110145
};
@@ -121,15 +156,16 @@ export const ImageWorkbenchNode: React.FC<NodeViewProps> = ({ node, updateAttrib
121156

122157
if (isTiff) {
123158
setIsDecoding(true);
124-
console.log("[TIFF] Uploading scientific file:", fileName);
159+
console.log("[TIFF] Upload Request:", fileName, `(${ (file.size / 1024 / 1024).toFixed(2) } MB)`);
125160
try {
126161
const buffer = await file.arrayBuffer();
127162
const dataUrl = await processTiff(buffer);
163+
console.log("[TIFF] Rendering to workbench...");
128164
updateAttributes({ src: dataUrl });
129165
} catch (err: unknown) {
130-
const msg = err instanceof Error ? err.message : 'Unknown error';
131-
console.error("[TIFF] Error processing upload:", msg);
132-
setImageLoadError(`TIFF Processing Error: ${msg}`);
166+
const msg = err instanceof Error ? err.message : 'Unknown decoding error';
167+
console.error("[TIFF] Failed to process upload:", msg);
168+
setImageLoadError(`TIFF Processing Failed: ${msg}. Try a different file or export as PNG/JPG.`);
133169
} finally {
134170
setIsDecoding(false);
135171
}
@@ -195,6 +231,7 @@ export const ImageWorkbenchNode: React.FC<NodeViewProps> = ({ node, updateAttrib
195231
const calculateDistance = useCallback((points: { x: number, y: number }[]) => {
196232
const p1 = points[0];
197233
const p2 = points[1];
234+
if (!p1 || !p2) return '0.0 px';
198235
const dx = p1.x - p2.x;
199236
const dy = p1.y - p2.y;
200237
const distPx = Math.sqrt(dx * dx + dy * dy);
@@ -223,11 +260,15 @@ export const ImageWorkbenchNode: React.FC<NodeViewProps> = ({ node, updateAttrib
223260
if (!isDrawing) return;
224261
setIsDrawing(false);
225262

263+
if (currentPoints.length < 2) {
264+
setCurrentPoints([]);
265+
return;
266+
}
267+
226268
if (activeTool === 'calibrate') {
227269
const umStr = window.prompt('Enter real-world length (µm) for this line:', '100');
228270
const um = parseFloat(umStr || '0');
229271
if (!isNaN(um) && um > 0) {
230-
if (currentPoints.length < 2) return;
231272
const dx = currentPoints[0].x - currentPoints[1].x;
232273
const dy = currentPoints[0].y - currentPoints[1].y;
233274
const pxDist = Math.sqrt(dx * dx + dy * dy);
@@ -236,7 +277,6 @@ export const ImageWorkbenchNode: React.FC<NodeViewProps> = ({ node, updateAttrib
236277
});
237278
}
238279
} else if (activeTool === 'measure' || activeTool === 'roi') {
239-
if (currentPoints.length < 2) return;
240280
const newAnnotation: Annotation = {
241281
id: Math.random().toString(36).substr(2, 9),
242282
type: activeTool,
@@ -368,54 +408,78 @@ export const ImageWorkbenchNode: React.FC<NodeViewProps> = ({ node, updateAttrib
368408
src={src}
369409
alt="Scientific Sample"
370410
className="max-w-full h-auto select-none pointer-events-none"
371-
onError={() => setImageLoadError("Failed to render decoded image. The file might be too large or uses an unsupported compression.")}
411+
onError={() => setImageLoadError("Browser failed to render the decoded binary. This can happen with massive files or corrupt streams.")}
372412
/>
373413
{imageLoadError && (
374-
<div className="absolute inset-0 flex flex-col items-center justify-center bg-black/70 backdrop-blur-sm z-20 p-8 text-center">
375-
<div className="p-3 bg-red-500/20 text-red-500 rounded-full mb-4">
376-
<AlertCircle className="w-8 h-8" />
414+
<div className="absolute inset-0 flex flex-col items-center justify-center bg-black/80 backdrop-blur-md z-30 p-10 text-center">
415+
<div className="p-4 bg-red-500/20 text-red-500 rounded-2xl mb-6 shadow-glow-red">
416+
<AlertCircle className="w-10 h-10" />
377417
</div>
378-
<h3 className="text-white font-bold mb-2 text-sm uppercase tracking-wider">Image Analysis Error</h3>
379-
<p className="text-neutral-400 text-[10px] max-w-xs">{imageLoadError}</p>
380-
<button
418+
<h3 className="text-white font-bold mb-3 text-lg tracking-tight">TIFF Rendering Error</h3>
419+
<p className="text-neutral-400 text-xs max-w-sm leading-relaxed mb-8">{imageLoadError}</p>
420+
421+
<div className="flex flex-col gap-3 w-full max-w-xs">
422+
<button
381423
onClick={() => fileInputRef.current?.click()}
382-
className="mt-6 px-4 py-2 bg-blue-600 hover:bg-blue-500 text-white rounded-xl text-xs font-bold transition-all shadow-lg flex items-center gap-2"
424+
className="px-6 py-3 bg-blue-600 hover:bg-blue-500 text-white rounded-xl text-sm font-bold transition-all shadow-lg flex items-center justify-center gap-2"
425+
>
426+
<Upload className="w-4 h-4" />
427+
Try Different File
428+
</button>
429+
<button
430+
onClick={() => window.location.reload()}
431+
className="px-6 py-2 bg-neutral-800 hover:bg-neutral-700 text-neutral-300 rounded-xl text-xs transition-all flex items-center justify-center gap-2"
383432
>
384433
<RefreshCw className="w-3 h-3" />
385-
Try Different Image
434+
Reload App
386435
</button>
436+
</div>
437+
438+
<div className="mt-8 pt-8 border-t border-white/5 flex items-center gap-2 text-neutral-500">
439+
<Info className="w-3 h-3" />
440+
<span className="text-[10px] italic">Check the browser console for exact error details</span>
441+
</div>
387442
</div>
388443
)}
389444
<canvas
390445
ref={canvasRef}
391446
className="absolute inset-0 pointer-events-none"
392447
/>
393448
{isDecoding && (
394-
<div className="absolute inset-0 flex flex-col items-center justify-center bg-black/80 backdrop-blur-md z-40">
395-
<div className="w-12 h-12 border-4 border-blue-500 border-t-transparent rounded-full animate-spin mb-4" />
396-
<p className="text-white text-xs font-bold uppercase tracking-widest animate-pulse">Decoding TIFF Layers...</p>
397-
<p className="text-neutral-500 text-[9px] mt-2 italic text-center px-6">Parsing scientific bit-depth for web visualization. This may take a moment for large microscopy files.</p>
449+
<div className="absolute inset-0 flex flex-col items-center justify-center bg-black/85 backdrop-blur-xl z-40">
450+
<div className="relative w-16 h-16 mb-6">
451+
<div className="absolute inset-0 border-4 border-blue-500/20 rounded-full" />
452+
<div className="absolute inset-0 border-4 border-blue-500 border-t-transparent rounded-full animate-spin" />
453+
</div>
454+
<p className="text-white text-xs font-bold uppercase tracking-[0.2em] animate-pulse">Decoding Scientific Data</p>
455+
<div className="flex flex-col items-center mt-3 text-neutral-500 text-[10px] space-y-1">
456+
<span>Converting high bit-depth TIFF to visual preview</span>
457+
<span>Applying adaptive scaling for workbench stability</span>
458+
</div>
398459
</div>
399460
)}
400461
</>
401462
) : (
402463
<div
403-
className="flex flex-col items-center gap-4 text-neutral-500 hover:text-blue-400 transition-colors cursor-pointer"
464+
className="flex flex-col items-center gap-4 text-neutral-500 hover:text-blue-400 transition-all cursor-pointer p-12 text-center"
404465
onClick={() => fileInputRef.current?.click()}
405466
>
406-
<div className="w-20 h-20 rounded-full bg-neutral-800 flex items-center justify-center border-2 border-dashed border-neutral-700 group-hover:border-blue-500/50 shadow-inner group-hover:scale-110 transition-transform duration-300">
407-
<Upload className="w-10 h-10" />
467+
<div className="w-24 h-24 rounded-3xl bg-neutral-800 flex items-center justify-center border-2 border-dashed border-neutral-700 group-hover:border-blue-500/50 shadow-inner group-hover:scale-105 transition-all duration-300 relative overflow-hidden">
468+
<div className="absolute inset-0 bg-blue-500/5 opacity-0 group-hover:opacity-100 transition-opacity" />
469+
<Upload className="w-10 h-10 group-hover:translate-y--2 transition-transform" />
408470
</div>
409-
<div className="text-center">
410-
<p className="text-sm font-bold text-neutral-300">Drop Scientific Image</p>
411-
<p className="text-[10px] text-neutral-500 mt-1 uppercase tracking-tighter">Supports PNG, JPG, TIF, TIFF</p>
471+
<div>
472+
<p className="text-lg font-bold text-neutral-200">Scientific Image Workbench</p>
473+
<p className="text-xs text-neutral-500 mt-2 max-w-xs leading-relaxed">
474+
Drag and drop your samples here. Supports raw <span className="text-blue-400/80 font-mono">.tif</span>, <span className="text-blue-400/80 font-mono">.tiff</span>, PNG, and JPEG.
475+
</p>
412476
</div>
413477
</div>
414478
)}
415479

416480
{/* TOOLBAR */}
417481
{src && !isDecoding && (
418-
<div className="absolute top-4 left-1/2 -translate-x-1/2 flex items-center gap-1 p-1.5 bg-black/60 backdrop-blur-md border border-white/10 rounded-2xl opacity-0 group-hover:opacity-100 transition-all duration-300 transform group-hover:translate-y-0 translate-y-2 shadow-2xl z-30">
482+
<div className="absolute top-4 left-1/2 -translate-x-1/2 flex items-center gap-1 p-1.5 bg-black/80 backdrop-blur-lg border border-white/10 rounded-2xl opacity-0 group-hover:opacity-100 transition-all duration-300 transform group-hover:translate-y-0 translate-y-2 shadow-2xl z-30">
419483
<ToolButton
420484
active={activeTool === 'select'}
421485
onClick={() => setActiveTool('select')}
@@ -460,19 +524,22 @@ export const ImageWorkbenchNode: React.FC<NodeViewProps> = ({ node, updateAttrib
460524
{/* STATUS BAR */}
461525
{src && !imageLoadError && !isDecoding && (
462526
<div className="absolute bottom-4 left-4 right-4 flex items-center justify-between pointer-events-none opacity-0 group-hover:opacity-100 transition-opacity">
463-
<div className="flex items-center gap-3 px-3 py-1.5 bg-black/60 backdrop-blur-md border border-white/10 rounded-full text-[10px] font-mono text-neutral-400 pointer-events-auto">
464-
<div className="flex items-center gap-1.5">
465-
<div className={`w-1.5 h-1.5 rounded-full ${calibration.ratio > 0 ? 'bg-emerald-500' : 'bg-amber-500'}`} />
466-
<span>{calibration.ratio > 0 ? `Calibrated: 1px = ${calibration.ratio.toFixed(2)}µm` : 'Uncalibrated'}</span>
527+
<div className="flex items-center gap-3 px-4 py-2 bg-black/80 backdrop-blur-lg border border-white/10 rounded-full text-[10px] font-mono text-neutral-300 pointer-events-auto shadow-lg">
528+
<div className="flex items-center gap-2">
529+
<div className={`w-2 h-2 rounded-full ${calibration.ratio > 0 ? 'bg-emerald-500 shadow-glow-emerald' : 'bg-amber-500 shadow-glow-amber'}`} />
530+
<span className="tracking-tight uppercase">{calibration.ratio > 0 ? `Scale: 1px = ${calibration.ratio.toFixed(2)}µm` : 'Uncalibrated'}</span>
531+
</div>
532+
<div className="w-px h-4 bg-white/15" />
533+
<div className="flex items-center gap-1.5 opacity-80">
534+
<Target className="w-3 h-3 text-blue-400" />
535+
<span>{annotations.length} Annotations</span>
467536
</div>
468-
<div className="w-px h-3 bg-white/10" />
469-
<span>{annotations.length} Annotations</span>
470537
</div>
471538

472539
{calibration.ratio > 0 && (
473-
<div className="flex items-center gap-2 px-3 py-1 bg-white/5 backdrop-blur-sm rounded-lg border border-white/10">
474-
<div className="w-10 h-px bg-white" />
475-
<span className="text-[10px] text-white font-mono">{calibration.um} µm</span>
540+
<div className="flex items-center gap-3 px-4 py-2 bg-white/5 backdrop-blur-md rounded-2xl border border-white/10 shadow-lg">
541+
<div className="w-8 h-[2px] bg-white/80 rounded-full" />
542+
<span className="text-[10px] text-white font-mono font-bold tracking-widest">{calibration.um} µm</span>
476543
</div>
477544
)}
478545
</div>
@@ -493,9 +560,9 @@ const ToolButton: React.FC<{ active: boolean, onClick: () => void, icon: React.R
493560
<button
494561
onClick={(e) => { e.preventDefault(); e.stopPropagation(); onClick(); }}
495562
title={label}
496-
className={`p-2 rounded-xl flex items-center justify-center transition-all ${
563+
className={`p-2.5 rounded-xl flex items-center justify-center transition-all duration-200 ${
497564
active
498-
? 'bg-blue-500/20 text-blue-400 shadow-[0_0_15px_rgba(59,130,246,0.3)]'
565+
? 'bg-blue-500/20 text-blue-400 shadow-[0_0_20px_rgba(59,130,246,0.3)] ring-1 ring-blue-500/40'
499566
: 'text-neutral-400 hover:bg-white/10 hover:text-white'
500567
}`}
501568
>

0 commit comments

Comments
 (0)