feat: SOTA AI photo colorization with DDColor deep learning model (#57) (#58)

Add AI-powered photo colorization that converts B&W/grayscale images to
full color using DDColor (ICCV 2023 dual-decoder architecture) via ONNX
Runtime. Includes model selection (Auto/DDColor/Classic), adjustable color
intensity, batch processing, before/after preview, and full HEIC/HEIF support.

Co-authored-by: stirling-image <stirling-image@users.noreply.github.com>
This commit is contained in:
stirling-image
2026-04-13 19:40:55 +08:00
committed by GitHub
co-authored by stirling-image
parent 58cdbe50b4
commit c280076098
12 changed files with 690 additions and 0 deletions
@@ -0,0 +1,154 @@
import { Download } from "lucide-react";
import { useState } from "react";
import { ProgressCard } from "@/components/common/progress-card";
import { useToolProcessor } from "@/hooks/use-tool-processor";
import { useFileStore } from "@/stores/file-store";
type Model = "auto" | "ddcolor" | "opencv";
const MODEL_OPTIONS: { value: Model; label: string; desc: string }[] = [
{ value: "auto", label: "Auto", desc: "Best available" },
{ value: "ddcolor", label: "DDColor", desc: "SOTA deep learning" },
{ value: "opencv", label: "Classic", desc: "Fast, lightweight" },
];
export function ColorizeSettings() {
const { files } = useFileStore();
const {
processFiles,
processAllFiles,
processing,
error,
downloadUrl,
originalSize,
processedSize,
progress,
} = useToolProcessor("colorize");
const [model, setModel] = useState<Model>("auto");
const [intensity, setIntensity] = useState(100);
const hasFile = files.length > 0;
const hasMultiple = files.length > 1;
const handleProcess = () => {
const settings = {
model,
intensity: intensity / 100,
};
if (hasMultiple) {
processAllFiles(files, settings);
} else {
processFiles(files, settings);
}
};
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
if (hasFile && !processing) handleProcess();
};
return (
<form onSubmit={handleSubmit} className="space-y-3">
{/* Model selector */}
<SectionLabel>AI Model</SectionLabel>
<div className="grid grid-cols-3 gap-1">
{MODEL_OPTIONS.map((opt) => (
<button
type="button"
key={opt.value}
onClick={() => setModel(opt.value)}
className={`text-xs py-2 rounded transition-colors ${
model === opt.value
? "bg-primary text-primary-foreground"
: "bg-muted text-muted-foreground hover:bg-primary/10"
}`}
>
<span className="block font-medium">{opt.label}</span>
<span className="block text-[10px] opacity-70">{opt.desc}</span>
</button>
))}
</div>
{/* Color intensity */}
<SectionLabel>Color Intensity</SectionLabel>
<div>
<div className="flex justify-between items-center">
<span className="text-xs text-muted-foreground">
{intensity === 0
? "Grayscale"
: intensity < 50
? "Subtle"
: intensity < 80
? "Natural"
: "Vivid"}
</span>
<span className="text-xs font-mono text-foreground tabular-nums w-10 text-right">
{intensity}%
</span>
</div>
<input
type="range"
min={10}
max={100}
step={5}
value={intensity}
onChange={(e) => setIntensity(Number(e.target.value))}
className="w-full mt-0.5"
/>
<p className="text-[10px] text-muted-foreground/60 mt-0.5">
Lower values produce more muted, vintage-style colors.
</p>
</div>
{error && <p className="text-xs text-red-500">{error}</p>}
{originalSize != null && processedSize != null && (
<div className="text-xs text-muted-foreground space-y-0.5">
<p>Original: {(originalSize / 1024).toFixed(1)} KB</p>
<p>Colorized: {(processedSize / 1024).toFixed(1)} KB</p>
</div>
)}
{processing ? (
<ProgressCard
active={processing}
phase={progress.phase === "idle" ? "uploading" : progress.phase}
label={hasMultiple ? `Colorizing ${files.length} images` : "Colorizing"}
stage={progress.stage}
percent={progress.percent}
elapsed={progress.elapsed}
/>
) : (
<button
type="submit"
data-testid="colorize-submit"
disabled={!hasFile || processing}
className="w-full py-2.5 rounded-lg bg-primary text-primary-foreground font-medium disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2"
>
{hasMultiple ? `Colorize (${files.length} files)` : "Colorize"}
</button>
)}
{!hasMultiple && downloadUrl && (
<a
href={downloadUrl}
download
data-testid="colorize-download"
className="w-full py-2.5 rounded-lg border border-primary text-primary font-medium flex items-center justify-center gap-2 hover:bg-primary/5"
>
<Download className="h-4 w-4" />
Download
</a>
)}
</form>
);
}
function SectionLabel({ children }: { children: React.ReactNode }) {
return (
<p className="text-[11px] font-semibold uppercase tracking-wider text-muted-foreground/70 pt-1">
{children}
</p>
);
}
@@ -53,6 +53,10 @@ export function getSettingsSummary(toolId: string, settings: Record<string, unkn
if (settings.scale) return `${settings.scale}x`;
return "";
}
case "colorize": {
const pct = settings.intensity != null ? Math.round(Number(settings.intensity) * 100) : 100;
return `${pct}% intensity`;
}
default:
return "";
}
+1
View File
@@ -14,6 +14,7 @@ const TOOL_SUGGESTIONS: Record<string, string[]> = {
"watermark-text": ["compress", "convert"],
"watermark-image": ["compress", "convert"],
"text-overlay": ["compress", "convert"],
colorize: ["adjust-colors", "image-enhancement", "upscale", "compress"],
sharpening: ["adjust-colors", "compress", "convert", "resize"],
border: ["compress", "convert", "resize"],
};
+6
View File
@@ -244,6 +244,11 @@ const ImageEnhancementSettings = lazy(() =>
default: m.ImageEnhancementSettings,
})),
);
const ColorizeSettings = lazy(() =>
import("@/components/tools/colorize-settings").then((m) => ({
default: m.ColorizeSettings,
})),
);
// ── Color tool wrapper ─────────────────────────────────────────────
// Color tools share a single component but differ by toolId.
@@ -372,6 +377,7 @@ export const toolRegistry = new Map<string, ToolRegistryEntry>([
Settings: ImageEnhancementSettings as never,
},
],
["colorize", { displayMode: "before-after", Settings: ColorizeSettings }],
]);
export function getToolRegistryEntry(toolId: string): ToolRegistryEntry | undefined {