Skip to content

Instantly share code, notes, and snippets.

@lovelaced
Created February 5, 2026 13:20
Show Gist options
  • Select an option

  • Save lovelaced/9aff050f1885d09497371c403272d5f0 to your computer and use it in GitHub Desktop.

Select an option

Save lovelaced/9aff050f1885d09497371c403272d5f0 to your computer and use it in GitHub Desktop.
b&w logo converter
import React, { useState, useRef, useCallback, useEffect } from 'react';
function calculateOtsuThreshold(imageData) {
const histogram = new Array(256).fill(0);
const data = imageData.data;
let totalPixels = 0;
for (let i = 0; i < data.length; i += 4) {
if (data[i + 3] > 0) {
const gray = Math.round(0.299 * data[i] + 0.587 * data[i + 1] + 0.114 * data[i + 2]);
histogram[gray]++;
totalPixels++;
}
}
if (totalPixels === 0) return 128;
let sum = 0;
for (let i = 0; i < 256; i++) sum += i * histogram[i];
let sumB = 0, wB = 0, maxVariance = 0, threshold = 128;
for (let t = 0; t < 256; t++) {
wB += histogram[t];
if (wB === 0) continue;
const wF = totalPixels - wB;
if (wF === 0) break;
sumB += t * histogram[t];
const mB = sumB / wB;
const mF = (sum - sumB) / wF;
const variance = wB * wF * (mB - mF) * (mB - mF);
if (variance > maxVariance) {
maxVariance = variance;
threshold = t;
}
}
return threshold;
}
function processImage(canvas, ctx, img, settings) {
const { threshold, autoThreshold, softness, invert, transparentBg, transparentColor, round, roundness, padding } = settings;
const srcWidth = img.naturalWidth || img.width;
const srcHeight = img.naturalHeight || img.height;
const maxDim = Math.max(srcWidth, srcHeight);
const paddingPx = round ? Math.round(maxDim * (padding / 100)) : 0;
if (round) {
canvas.width = maxDim + paddingPx * 2;
canvas.height = maxDim + paddingPx * 2;
} else {
canvas.width = srcWidth;
canvas.height = srcHeight;
}
ctx.clearRect(0, 0, canvas.width, canvas.height);
const offsetX = round ? (canvas.width - srcWidth) / 2 : 0;
const offsetY = round ? (canvas.height - srcHeight) / 2 : 0;
ctx.drawImage(img, offsetX, offsetY);
const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height);
const data = imageData.data;
const grayValues = new Float32Array(canvas.width * canvas.height);
for (let i = 0; i < data.length; i += 4) {
grayValues[i / 4] = 0.299 * data[i] + 0.587 * data[i + 1] + 0.114 * data[i + 2];
}
const finalThreshold = autoThreshold ? calculateOtsuThreshold(imageData) : threshold;
const centerX = canvas.width / 2;
const centerY = canvas.height / 2;
const maskRadius = Math.min(canvas.width, canvas.height) / 2;
const softRange = softness * 2.5;
for (let i = 0; i < data.length; i += 4) {
const pixelIndex = i / 4;
const x = pixelIndex % canvas.width;
const y = Math.floor(pixelIndex / canvas.width);
let maskAlpha = 255;
// Round mask
if (round) {
const dx = x - centerX;
const dy = y - centerY;
if (roundness >= 100) {
const dist = Math.sqrt(dx * dx + dy * dy);
if (dist > maskRadius) {
data[i] = data[i + 1] = data[i + 2] = 0;
data[i + 3] = 0;
continue;
}
if (dist > maskRadius - 1.5) {
maskAlpha = Math.round(255 * Math.max(0, (maskRadius - dist) / 1.5));
}
} else {
const cornerRadius = (roundness / 100) * maskRadius;
const halfW = canvas.width / 2;
const halfH = canvas.height / 2;
const ax = Math.abs(dx);
const ay = Math.abs(dy);
if (ax > halfW - cornerRadius && ay > halfH - cornerRadius) {
const cx = ax - (halfW - cornerRadius);
const cy = ay - (halfH - cornerRadius);
const cornerDist = Math.sqrt(cx * cx + cy * cy);
if (cornerDist > cornerRadius) {
data[i] = data[i + 1] = data[i + 2] = 0;
data[i + 3] = 0;
continue;
}
if (cornerDist > cornerRadius - 1.5) {
maskAlpha = Math.round(255 * Math.max(0, (cornerRadius - cornerDist) / 1.5));
}
}
}
}
const originalAlpha = data[i + 3];
if (originalAlpha < 10) {
data[i] = data[i + 1] = data[i + 2] = 0;
data[i + 3] = 0;
continue;
}
const gray = grayValues[pixelIndex];
let bw;
if (softness === 0) {
bw = gray < finalThreshold ? 0 : 255;
} else {
const edgeRadius = Math.max(1, Math.ceil(softness / 15));
let isNearEdge = false;
const myBinary = gray < finalThreshold;
outer:
for (let dy = -edgeRadius; dy <= edgeRadius; dy++) {
for (let dx = -edgeRadius; dx <= edgeRadius; dx++) {
const nx = x + dx;
const ny = y + dy;
if (nx >= 0 && nx < canvas.width && ny >= 0 && ny < canvas.height) {
const nidx = ny * canvas.width + nx;
const neighborBinary = grayValues[nidx] < finalThreshold;
if (neighborBinary !== myBinary) {
isNearEdge = true;
break outer;
}
}
}
}
if (isNearEdge) {
let avgGray = 0;
let count = 0;
const sampleRadius = Math.max(1, Math.ceil(softness / 20));
for (let dy = -sampleRadius; dy <= sampleRadius; dy++) {
for (let dx = -sampleRadius; dx <= sampleRadius; dx++) {
const nx = x + dx;
const ny = y + dy;
if (nx >= 0 && nx < canvas.width && ny >= 0 && ny < canvas.height) {
const dist = Math.sqrt(dx * dx + dy * dy);
if (dist <= sampleRadius) {
const weight = 1 - (dist / (sampleRadius + 1));
const nidx = ny * canvas.width + nx;
avgGray += grayValues[nidx] * weight;
count += weight;
}
}
}
}
avgGray /= count;
const distFromThreshold = avgGray - finalThreshold;
const t = 1 / (1 + Math.exp(-distFromThreshold / (softRange / 3)));
bw = Math.round(t * 255);
} else {
bw = gray < finalThreshold ? 0 : 255;
}
}
if (invert) bw = 255 - bw;
// Calculate final alpha
let finalAlpha = Math.min(originalAlpha, maskAlpha);
if (transparentBg) {
if (transparentColor === 'white') {
// White becomes transparent, black stays opaque
const colorAlpha = 255 - bw;
finalAlpha = Math.round((finalAlpha / 255) * colorAlpha);
data[i] = data[i + 1] = data[i + 2] = 0;
} else {
// Black becomes transparent, white stays opaque
const colorAlpha = bw;
finalAlpha = Math.round((finalAlpha / 255) * colorAlpha);
data[i] = data[i + 1] = data[i + 2] = 255;
}
} else {
data[i] = data[i + 1] = data[i + 2] = bw;
}
data[i + 3] = finalAlpha;
}
ctx.putImageData(imageData, 0, 0);
return finalThreshold;
}
export default function LogoConverter() {
const [image, setImage] = useState(null);
const [imageName, setImageName] = useState('');
const [settings, setSettings] = useState({
threshold: 128,
autoThreshold: true,
softness: 40,
invert: false,
transparentBg: true,
transparentColor: 'white', // 'white' or 'black'
round: false,
roundness: 100,
padding: 0
});
const [computedThreshold, setComputedThreshold] = useState(128);
const [previewKey, setPreviewKey] = useState(0);
const [previewBg, setPreviewBg] = useState('dark');
const [dragOver, setDragOver] = useState(false);
const originalCanvasRef = useRef(null);
const processedCanvasRef = useRef(null);
const fileInputRef = useRef(null);
const processCurrentImage = useCallback(() => {
if (!image || !processedCanvasRef.current) return;
const ctx = processedCanvasRef.current.getContext('2d');
const threshold = processImage(processedCanvasRef.current, ctx, image, settings);
setComputedThreshold(threshold);
setPreviewKey(k => k + 1);
}, [image, settings]);
useEffect(() => {
processCurrentImage();
}, [processCurrentImage]);
useEffect(() => {
if (!image || !originalCanvasRef.current) return;
const canvas = originalCanvasRef.current;
canvas.width = image.naturalWidth || image.width;
canvas.height = image.naturalHeight || image.height;
const ctx = canvas.getContext('2d');
ctx.clearRect(0, 0, canvas.width, canvas.height);
ctx.drawImage(image, 0, 0);
}, [image]);
const handleFile = (file) => {
if (!file || !file.type.startsWith('image/')) return;
setImageName(file.name);
const reader = new FileReader();
reader.onload = (e) => {
const img = new Image();
img.onload = () => setImage(img);
img.src = e.target.result;
};
reader.readAsDataURL(file);
};
const handleDrop = (e) => {
e.preventDefault();
setDragOver(false);
handleFile(e.dataTransfer.files[0]);
};
const handleExport = (format) => {
if (!processedCanvasRef.current) return;
const link = document.createElement('a');
const baseName = imageName.replace(/\.[^/.]+$/, '') || 'logo';
link.download = `${baseName}-bw.${format}`;
link.href = processedCanvasRef.current.toDataURL(`image/${format}`, 1.0);
link.click();
};
const updateSetting = (key, value) => {
setSettings(prev => ({ ...prev, [key]: value }));
};
const PreviewCanvas = ({ size, className }) => (
<canvas
key={`preview-${size}-${previewKey}`}
width={size}
height={size}
ref={el => {
if (el && processedCanvasRef.current) {
const ctx = el.getContext('2d');
ctx.clearRect(0, 0, size, size);
ctx.drawImage(processedCanvasRef.current, 0, 0, size, size);
}
}}
className={className}
/>
);
return (
<div className="min-h-screen bg-zinc-950 text-zinc-100 p-6">
<div className="max-w-6xl mx-auto">
<div className="mb-8">
<h1 className="text-2xl font-semibold mb-2">Image → Logo</h1>
<p className="text-zinc-400">Turn any image into a high-contrast logo</p>
</div>
{!image ? (
<div
onDrop={handleDrop}
onDragOver={(e) => { e.preventDefault(); setDragOver(true); }}
onDragLeave={() => setDragOver(false)}
onClick={() => fileInputRef.current?.click()}
className={`border-2 border-dashed rounded-xl p-16 text-center cursor-pointer transition-all ${
dragOver ? 'border-white bg-zinc-900' : 'border-zinc-700 hover:border-zinc-500'
}`}
>
<div className="text-5xl mb-4">↓</div>
<p className="text-lg mb-2">Drop an image here</p>
<p className="text-zinc-500">or click to browse</p>
<input
ref={fileInputRef}
type="file"
accept="image/*"
onChange={(e) => handleFile(e.target.files[0])}
className="hidden"
/>
</div>
) : (
<div className="grid lg:grid-cols-3 gap-6">
<div className="lg:col-span-2 space-y-4">
<div className="grid md:grid-cols-2 gap-4">
<div className="bg-zinc-900 rounded-xl p-4">
<div className="text-sm text-zinc-500 mb-3">Original</div>
<div className="bg-[repeating-conic-gradient(#333_0%_25%,#222_0%_50%)] bg-[length:16px_16px] rounded-lg flex items-center justify-center min-h-48 p-4">
<canvas ref={originalCanvasRef} className="max-w-full max-h-64 object-contain" />
</div>
</div>
<div className="bg-zinc-900 rounded-xl p-4">
<div className="text-sm text-zinc-500 mb-3">Processed</div>
<div className="bg-[repeating-conic-gradient(#333_0%_25%,#222_0%_50%)] bg-[length:16px_16px] rounded-lg flex items-center justify-center min-h-48 p-4">
<canvas ref={processedCanvasRef} className="max-w-full max-h-64 object-contain" />
</div>
</div>
</div>
{/* Contextual previews */}
<div className="bg-zinc-900 rounded-xl p-4">
<div className="flex justify-between items-center mb-4">
<div className="text-sm text-zinc-500">Preview in context</div>
<div className="flex gap-1">
{['dark', 'light'].map(id => (
<button
key={id}
onClick={() => setPreviewBg(id)}
className={`px-2 py-1 text-xs rounded capitalize ${
previewBg === id ? 'bg-zinc-700 text-white' : 'text-zinc-500 hover:text-zinc-300'
}`}
>
{id}
</button>
))}
</div>
</div>
<div className="space-y-4">
{/* Browser tab */}
<div className={`rounded-lg overflow-hidden ${previewBg === 'dark' ? 'bg-zinc-800' : 'bg-gray-200'}`}>
<div className={`flex items-center gap-2 px-2 py-1.5 ${previewBg === 'dark' ? 'bg-zinc-700' : 'bg-gray-300'}`}>
<div className={`flex items-center gap-2 px-2 py-1 rounded-t text-xs ${previewBg === 'dark' ? 'bg-zinc-800 text-zinc-300' : 'bg-white text-gray-700'}`}>
<PreviewCanvas size={16} className="w-4 h-4" />
<span>My Website</span>
<span className={previewBg === 'dark' ? 'text-zinc-500' : 'text-gray-400'}>×</span>
</div>
</div>
<div className={`h-6 ${previewBg === 'dark' ? 'bg-zinc-800' : 'bg-white'}`} />
</div>
{/* Website header */}
<div className={`rounded-lg overflow-hidden ${previewBg === 'dark' ? 'bg-zinc-800' : 'bg-white'}`}>
<div className={`flex items-center justify-between px-4 py-3 border-b ${previewBg === 'dark' ? 'border-zinc-700' : 'border-gray-200'}`}>
<div className="flex items-center gap-3">
<PreviewCanvas size={32} className="w-8 h-8" />
<span className={`font-medium ${previewBg === 'dark' ? 'text-white' : 'text-gray-900'}`}>Brand Name</span>
</div>
<div className={`flex gap-4 text-sm ${previewBg === 'dark' ? 'text-zinc-400' : 'text-gray-600'}`}>
<span>Products</span>
<span>About</span>
<span>Contact</span>
</div>
</div>
<div className="h-12" />
</div>
{/* App icon + bookmarks */}
<div className="flex gap-6">
<div className="flex flex-col items-center gap-2">
<div className={`p-3 rounded-2xl ${previewBg === 'dark' ? 'bg-zinc-800' : 'bg-white shadow-md'}`}>
<PreviewCanvas size={60} className="w-[60px] h-[60px] rounded-xl" />
</div>
<span className={`text-xs ${previewBg === 'dark' ? 'text-zinc-400' : 'text-gray-600'}`}>App Icon</span>
</div>
<div className="flex-1">
<div className={`rounded-lg px-3 py-2 ${previewBg === 'dark' ? 'bg-zinc-800' : 'bg-gray-100'}`}>
<div className="flex items-center gap-4">
<div className="flex items-center gap-2">
<PreviewCanvas size={16} className="w-4 h-4" />
<span className={`text-xs ${previewBg === 'dark' ? 'text-zinc-400' : 'text-gray-600'}`}>Your Site</span>
</div>
<div className={`flex items-center gap-2 ${previewBg === 'dark' ? 'text-zinc-600' : 'text-gray-400'}`}>
<div className="w-4 h-4 rounded bg-current opacity-30" />
<span className="text-xs">Other</span>
</div>
</div>
</div>
<span className={`text-xs mt-1 block ${previewBg === 'dark' ? 'text-zinc-500' : 'text-gray-500'}`}>Bookmarks bar</span>
</div>
</div>
</div>
</div>
<div className="flex gap-3">
<button
onClick={() => handleExport('png')}
className="flex-1 bg-white text-black font-medium py-3 px-4 rounded-lg hover:bg-zinc-200 transition-colors"
>
Export PNG
</button>
<button
onClick={() => handleExport('webp')}
className="flex-1 bg-zinc-800 font-medium py-3 px-4 rounded-lg hover:bg-zinc-700 transition-colors"
>
Export WebP
</button>
<button
onClick={() => { setImage(null); setImageName(''); }}
className="bg-zinc-800 font-medium py-3 px-4 rounded-lg hover:bg-zinc-700 transition-colors"
>
Reset
</button>
</div>
</div>
<div className="bg-zinc-900 rounded-xl p-5 space-y-6 h-fit">
<div className="text-sm font-medium text-zinc-400 uppercase tracking-wide">Settings</div>
<div>
<label className="flex items-center justify-between mb-3">
<span>Auto Threshold (Otsu)</span>
<button
onClick={() => updateSetting('autoThreshold', !settings.autoThreshold)}
className={`w-12 h-6 rounded-full transition-colors relative ${
settings.autoThreshold ? 'bg-white' : 'bg-zinc-700'
}`}
>
<div className={`absolute top-0.5 w-5 h-5 rounded-full bg-zinc-900 transition-all ${
settings.autoThreshold ? 'left-6' : 'left-0.5'
}`} />
</button>
</label>
{settings.autoThreshold && (
<div className="text-sm text-zinc-500">Computed threshold: {computedThreshold}</div>
)}
</div>
{!settings.autoThreshold && (
<div>
<label className="flex justify-between mb-2">
<span>Manual Threshold</span>
<span className="text-zinc-500">{settings.threshold}</span>
</label>
<input
type="range"
min="0"
max="255"
value={settings.threshold}
onChange={(e) => updateSetting('threshold', parseInt(e.target.value))}
className="w-full accent-white"
/>
</div>
)}
<div>
<label className="flex justify-between mb-2">
<span>Edge Smoothing</span>
<span className="text-zinc-500">{settings.softness}</span>
</label>
<input
type="range"
min="0"
max="100"
value={settings.softness}
onChange={(e) => updateSetting('softness', parseInt(e.target.value))}
className="w-full accent-white"
/>
</div>
<div>
<label className="flex items-center justify-between">
<span>Transparent Background</span>
<button
onClick={() => updateSetting('transparentBg', !settings.transparentBg)}
className={`w-12 h-6 rounded-full transition-colors relative ${
settings.transparentBg ? 'bg-white' : 'bg-zinc-700'
}`}
>
<div className={`absolute top-0.5 w-5 h-5 rounded-full bg-zinc-900 transition-all ${
settings.transparentBg ? 'left-6' : 'left-0.5'
}`} />
</button>
</label>
{settings.transparentBg && (
<div className="mt-3 flex gap-2">
<button
onClick={() => updateSetting('transparentColor', 'white')}
className={`flex-1 py-2 px-3 rounded text-sm flex items-center justify-center gap-2 ${
settings.transparentColor === 'white'
? 'bg-zinc-700 text-white'
: 'bg-zinc-800 text-zinc-400 hover:text-zinc-300'
}`}
>
<div className="w-4 h-4 rounded bg-white border border-zinc-600" />
White → Clear
</button>
<button
onClick={() => updateSetting('transparentColor', 'black')}
className={`flex-1 py-2 px-3 rounded text-sm flex items-center justify-center gap-2 ${
settings.transparentColor === 'black'
? 'bg-zinc-700 text-white'
: 'bg-zinc-800 text-zinc-400 hover:text-zinc-300'
}`}
>
<div className="w-4 h-4 rounded bg-black border border-zinc-600" />
Black → Clear
</button>
</div>
)}
</div>
<div>
<label className="flex items-center justify-between">
<span>Invert</span>
<button
onClick={() => updateSetting('invert', !settings.invert)}
className={`w-12 h-6 rounded-full transition-colors relative ${
settings.invert ? 'bg-white' : 'bg-zinc-700'
}`}
>
<div className={`absolute top-0.5 w-5 h-5 rounded-full bg-zinc-900 transition-all ${
settings.invert ? 'left-6' : 'left-0.5'
}`} />
</button>
</label>
</div>
<div className="border-t border-zinc-800 pt-6">
<label className="flex items-center justify-between mb-3">
<span>Round Mask</span>
<button
onClick={() => updateSetting('round', !settings.round)}
className={`w-12 h-6 rounded-full transition-colors relative ${
settings.round ? 'bg-white' : 'bg-zinc-700'
}`}
>
<div className={`absolute top-0.5 w-5 h-5 rounded-full bg-zinc-900 transition-all ${
settings.round ? 'left-6' : 'left-0.5'
}`} />
</button>
</label>
{settings.round && (
<div className="space-y-4">
<div>
<label className="flex justify-between mb-2 text-sm">
<span className="text-zinc-400">Roundness</span>
<span className="text-zinc-500">{settings.roundness}%</span>
</label>
<input
type="range"
min="10"
max="100"
value={settings.roundness}
onChange={(e) => updateSetting('roundness', parseInt(e.target.value))}
className="w-full accent-white"
/>
</div>
<div>
<label className="flex justify-between mb-2 text-sm">
<span className="text-zinc-400">Padding</span>
<span className="text-zinc-500">{settings.padding}%</span>
</label>
<input
type="range"
min="0"
max="30"
value={settings.padding}
onChange={(e) => updateSetting('padding', parseInt(e.target.value))}
className="w-full accent-white"
/>
</div>
</div>
)}
</div>
</div>
</div>
)}
</div>
</div>
);
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment