Deblur / index.html
Shafi99's picture
Readable message for damaged files, accept HEIC in the picker
7cbef1d verified
Raw History Blame Contribute Delete
47.3 kB
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
<title>Deblur</title>
<meta name="description" content="Remove motion blur from photos. Full resolution, uncompressed, free.">
<meta name="color-scheme" content="dark light">
<meta name="theme-color" content="#17181b">
<link rel="canonical" href="https://shafi99-deblur.static.hf.space/">
<meta property="og:type" content="website">
<meta property="og:site_name" content="Deblur">
<meta property="og:locale" content="en_US">
<meta property="og:title" content="Deblur">
<meta property="og:description" content="Remove motion blur from photos. Full resolution in, uncompressed PNG out. Free, no sign-up.">
<meta property="og:url" content="https://shafi99-deblur.static.hf.space/">
<meta property="og:image" content="https://shafi99-deblur.static.hf.space/og.jpg">
<meta property="og:image:secure_url" content="https://shafi99-deblur.static.hf.space/og.jpg">
<meta property="og:image:type" content="image/jpeg">
<meta property="og:image:width" content="1200">
<meta property="og:image:height" content="630">
<meta property="og:image:alt" content="A skateboarder crossing a street, smeared by motion on the left and sharp on the right.">
<meta name="twitter:card" content="summary_large_image">
<meta name="twitter:title" content="Deblur">
<meta name="twitter:description" content="Remove motion blur from photos. Full resolution in, uncompressed PNG out. Free, no sign-up.">
<meta name="twitter:image" content="https://shafi99-deblur.static.hf.space/og.jpg">
<meta name="twitter:image:alt" content="A skateboarder crossing a street, smeared by motion on the left and sharp on the right.">
<link rel="manifest" href="manifest.webmanifest">
<link rel="icon" href="mark.svg" type="image/svg+xml">
<link rel="icon" href="icon-32.png" type="image/png" sizes="32x32">
<link rel="icon" href="icon-192.png" type="image/png" sizes="192x192">
<link rel="apple-touch-icon" href="icon-180.png">
<script>
// run before first paint so the page never flashes the wrong theme
try{ var t = localStorage.getItem("theme"); if(t) document.documentElement.dataset.theme = t; }catch(e){}
</script>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Newsreader:ital,opsz,wght@0,6..72,400;0,6..72,500;0,6..72,600;0,6..72,700;1,6..72,300;1,6..72,400;1,6..72,500&family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
<style>
:root{
/* dark is the default; light is opt-in through the toggle */
--paper:#17181b; --surface:#1f2124; --ink:#e4e3df; --muted:#989ca2;
--rule:#2e3034; --rule-soft:#232528; --accent:#dd8f74; --accent-soft:#2b1f1a;
--on-ink:#17181b; --on-ink-dim:#6a6e73; --accent-lift:#b4573f;
--ok:#6aab8b;
--stage:#121316;
--serif:"Newsreader",Georgia,"Times New Roman",serif;
--sans:"Inter",system-ui,-apple-system,"Segoe UI",Roboto,sans-serif;
--measure:760px; --wide:900px;
--ease:cubic-bezier(.25,.8,.3,1);
}
:root[data-theme="light"]{
--paper:#f9f8f6; --surface:#fefefd; --ink:#2b2b30; --muted:#696c73;
--rule:#e7e6e2; --rule-soft:#f1f0ed; --accent:#ae5038; --accent-soft:#f7eae5;
--on-ink:#f4f3f1; --on-ink-dim:#aeb0b4; --accent-lift:#e39277;
--ok:#4e8468;
--stage:#24242a;
}
*{box-sizing:border-box}
html{-webkit-text-size-adjust:100%}
body{margin:0;background:var(--paper);color:var(--ink);
font:400 17.5px/1.75 var(--serif);
-webkit-font-smoothing:antialiased;text-rendering:optimizeLegibility;
min-height:100svh;overflow-x:hidden;
padding:env(safe-area-inset-top) env(safe-area-inset-right) env(safe-area-inset-bottom) env(safe-area-inset-left)}
.wrap{max-width:var(--wide);margin:0 auto;padding:40px 24px 72px}
.fade{opacity:0;transform:translateY(11px);animation:fade .52s var(--ease) forwards}
header.fade{animation-delay:.02s} .masthead.fade{animation-delay:.07s}
.card.fade{animation-delay:.13s} .info.fade{animation-delay:.2s}
@keyframes fade{to{opacity:1;transform:none}}
header{display:flex;align-items:baseline;justify-content:space-between;gap:16px;
padding-bottom:16px;margin-bottom:40px;position:relative}
header::after{content:"";position:absolute;left:0;right:0;bottom:0;height:1px;background:var(--ink);
transform:scaleX(0);transform-origin:left;animation:draw .95s var(--ease) .2s forwards}
@keyframes draw{to{transform:scaleX(1)}}
.mark{font:700 clamp(1.75rem,3.4vw,2.125rem)/1 var(--serif);letter-spacing:-.035em}
.mark i{font-style:italic;color:var(--accent)}
.hdr-right{display:flex;align-items:center;gap:10px}
.themebtn{width:34px;height:34px;flex:none;border-radius:50%;border:1px solid var(--rule);
background:var(--surface);color:var(--muted);cursor:pointer;display:grid;place-items:center;
font:400 15px/1 var(--sans);padding:0;
transition:color .22s var(--ease),border-color .22s var(--ease),transform .3s var(--ease)}
.themebtn:hover{color:var(--accent);border-color:var(--accent);transform:rotate(-18deg)}
.status{font:600 .6875rem/1 var(--sans);letter-spacing:.1em;text-transform:uppercase;
display:inline-flex;align-items:center;gap:8px;white-space:nowrap;
padding:7px 13px;border-radius:999px;border:1px solid var(--rule);background:var(--surface);
color:var(--muted);transition:color .3s var(--ease),border-color .3s var(--ease),background .3s var(--ease)}
.dot{width:7px;height:7px;border-radius:50%;background:currentColor;flex:none;position:relative}
.dot::after{content:"";position:absolute;inset:0;border-radius:50%;background:currentColor;
opacity:0;animation:ripple 2s var(--ease) infinite}
.status.is-ok{color:var(--ok);border-color:color-mix(in srgb,var(--ok) 38%,transparent);
background:color-mix(in srgb,var(--ok) 9%,var(--surface))}
.status.is-wait{color:var(--accent);border-color:color-mix(in srgb,var(--accent) 38%,transparent);
background:color-mix(in srgb,var(--accent) 9%,var(--surface))}
.status.is-bad{color:var(--accent);border-color:color-mix(in srgb,var(--accent) 45%,transparent);
background:color-mix(in srgb,var(--accent) 12%,var(--surface))}
.status.is-wait .dot::after{animation-duration:1.1s}
.status.is-ok .dot::after{animation-duration:2.4s}
@keyframes ripple{0%{opacity:.55;transform:scale(1)}70%{opacity:0;transform:scale(3.1)}100%{opacity:0}}
.masthead{max-width:none}
@media (min-width:620px){ h1{white-space:nowrap} }
h1{font:600 clamp(2.3rem,6vw,3.6rem)/1.06 var(--serif);letter-spacing:-.025em;margin:0 0 16px}
h1 em{font-style:italic;color:var(--accent)}
.lede{margin:0 0 34px;color:var(--muted);font-size:1.0625rem;font-style:italic;max-width:44ch}
button,a.btn{font:inherit;color:inherit}
.btn{height:44px;padding:0 20px;border-radius:2px;border:1px solid var(--ink);
background:transparent;color:var(--ink);cursor:pointer;display:inline-flex;align-items:center;
justify-content:center;gap:8px;text-decoration:none;
font:500 .875rem/1 var(--sans);letter-spacing:.02em;position:relative;overflow:hidden;
isolation:isolate;transition:color .28s var(--ease),border-color .28s var(--ease)}
.btn::after{content:"";position:absolute;inset:0;background:var(--ink);z-index:0;
transform:scaleX(0);transform-origin:right;transition:transform .34s var(--ease)}
.btn span{position:relative;z-index:1}
.btn:hover{color:var(--paper)}
.btn:hover::after{transform:scaleX(1);transform-origin:left}
/* the primary button inverts outright: a light field wipes across the dark one and the
label flips from cream to ink */
.btn.primary{background:var(--ink);color:var(--paper);border-color:var(--ink)}
.btn.primary::after{background:var(--paper)}
.btn.primary:hover{color:var(--ink);border-color:var(--ink)}
.btn[aria-pressed="true"]{border-color:var(--accent);color:var(--accent)}
.btn[aria-pressed="true"]::after{background:var(--accent-soft)}
.btn[aria-pressed="true"]:hover{color:var(--accent)}
:focus-visible{outline:2px solid var(--accent);outline-offset:3px}
.drop:has(:focus-visible) .btn.primary{outline:2px solid var(--accent);outline-offset:3px}
.card{position:relative;border:1px solid var(--rule);background:var(--surface);overflow:hidden;
transition:border-color .3s var(--ease),box-shadow .3s var(--ease)}
/* crop marks, the way a contact sheet frames a plate */
.crop{position:absolute;inset:0;pointer-events:none;z-index:2}
.crop i{position:absolute;width:15px;height:15px;border:1px solid var(--rule);
transition:border-color .3s var(--ease),width .3s var(--ease),height .3s var(--ease)}
.crop i:nth-child(1){top:13px;left:13px;border-right:0;border-bottom:0}
.crop i:nth-child(2){top:13px;right:13px;border-left:0;border-bottom:0}
.crop i:nth-child(3){bottom:13px;left:13px;border-right:0;border-top:0}
.crop i:nth-child(4){bottom:13px;right:13px;border-left:0;border-top:0}
.card:hover .crop i{width:21px;height:21px}
.card:has(.drop.over) .crop i{border-color:var(--accent);width:24px;height:24px}
.card:hover{box-shadow:0 1px 2px rgba(0,0,0,.02),0 14px 44px -28px rgba(0,0,0,.26)}
.drop{position:relative;display:flex;flex-direction:column;align-items:center;justify-content:center;
gap:22px;min-height:min(54vh,420px);padding:56px 24px;text-align:center;cursor:pointer;
transition:background .3s var(--ease)}
.drop:hover{background:var(--rule-soft)}
.drop::before{content:"";position:absolute;inset:26px;border:1px dashed var(--rule);
opacity:0;transform:scale(.99);pointer-events:none;
transition:opacity .3s var(--ease),transform .3s var(--ease),border-color .3s var(--ease)}
.drop:hover::before{opacity:1;transform:scale(1)}
.drop.over{background:var(--accent-soft)}
.drop.over::before{opacity:1;transform:scale(1);border-color:var(--accent)}
.drop.over .rule-x{width:96px;background:var(--accent)}
.drop.over .lens{transform:rotate(90deg)}
.drop.over .lens::before,.drop.over .lens::after{border-color:var(--accent)}
.drop p{margin:0;color:var(--muted);font-size:.9375rem;font-style:italic;position:relative}
.lens{width:46px;height:46px;position:relative;transition:transform .5s var(--ease)}
.lens::before,.lens::after{content:"";position:absolute;inset:0;border:1px solid var(--muted);
transition:border-color .3s var(--ease)}
.lens::after{transform:rotate(45deg)}
.rule-x{height:1px;width:48px;background:var(--rule);transition:width .4s var(--ease)}
.viewer{position:relative;touch-action:pan-y;user-select:none;-webkit-user-select:none;
background:var(--stage);display:none;cursor:ew-resize}
.viewer.zoomed{cursor:grab;touch-action:none}
.stage img{image-rendering:-webkit-optimize-contrast}
.stage{position:absolute;left:0;top:0;transform-origin:0 0;will-change:transform}
.stage img,.stage canvas{position:absolute;left:0;top:0;width:100%;height:100%;display:block;pointer-events:none}
.stage canvas{opacity:0;transition:opacity .25s var(--ease)}
.stage canvas.on{opacity:1}
.divider{position:absolute;top:0;bottom:0;width:0;pointer-events:none}
.divider::before{content:"";position:absolute;left:-.5px;top:0;bottom:0;width:1px;background:#fff;
box-shadow:0 0 0 1px rgba(0,0,0,.18)}
.knob{position:absolute;left:0;top:50%;width:36px;height:36px;margin:-18px 0 0 -18px;border-radius:50%;
background:var(--paper);border:1px solid var(--rule);pointer-events:auto;display:grid;place-items:center;
color:var(--ink);font:500 11px/1 var(--sans);box-shadow:0 2px 12px rgba(0,0,0,.3);
transition:transform .28s var(--ease),background .25s var(--ease)}
.knob:hover{transform:scale(1.14);background:var(--accent);color:#fff;border-color:var(--accent)}
.knob:focus-visible{outline:2px solid #fff;outline-offset:2px}
.tag{position:absolute;bottom:14px;padding:3px 9px;background:rgba(23,22,26,.72);color:#fff;
font:500 .625rem/1.6 var(--sans);letter-spacing:.13em;pointer-events:none}
.tag.l{left:14px}.tag.r{right:14px}
.working .divider,.working .tag{display:none}
.working .stage img{animation:pull 1.8s var(--ease) infinite alternate}
.working::after{content:"";position:absolute;inset:0;pointer-events:none;
background:linear-gradient(100deg,transparent 44%,rgba(255,255,255,.13) 50%,transparent 56%);
background-size:260% 100%;animation:sweep 2s linear infinite}
@keyframes pull{from{filter:blur(7px)}to{filter:blur(.4px)}}
@keyframes sweep{from{background-position:130% 0}to{background-position:-130% 0}}
.progress{height:2px;background:var(--rule);overflow:hidden;margin:18px 0 0;display:none;
border-radius:2px}
.progress>i{display:block;height:100%;width:0;background:var(--accent);transition:width .3s var(--ease)}
.progress.indet>i{width:28%;animation:slide 1.3s var(--ease) infinite}
@keyframes slide{from{margin-left:-28%}to{margin-left:100%}}
.msg{min-height:1.5em;margin:12px 0 0;color:var(--muted);font:400 .875rem/1.6 var(--sans);
font-variant-numeric:tabular-nums}
/* step rail, so the wait has a shape instead of a spinner */
.steps{display:none;gap:0;margin:14px 0 0;align-items:center;
font:600 .625rem/1 var(--sans);letter-spacing:.11em;text-transform:uppercase;color:var(--muted)}
.steps.on{display:flex}
.steps .st{display:inline-flex;align-items:center;gap:7px;opacity:.38;transition:opacity .3s var(--ease),color .3s var(--ease)}
.steps .st b{width:6px;height:6px;border-radius:50%;background:currentColor;display:block}
.steps .st.now{opacity:1;color:var(--accent)}
.steps .st.done{opacity:1;color:var(--ok)}
.steps .gap{flex:1;height:1px;background:var(--rule);margin:0 10px;min-width:16px}
.eta{color:var(--ink);font-weight:500}
.msg.err{color:var(--accent)}
.bar{display:none;flex-wrap:wrap;gap:10px;margin-top:18px;align-items:center}
.bar .sp{flex:1}
/* research, set like a reference list */
.info{margin-top:72px;max-width:none}
.info h2{font:700 .6875rem/1 var(--sans);letter-spacing:.16em;text-transform:uppercase;
color:var(--ink);margin:0 0 8px}
.info .hr{height:1px;background:var(--ink);margin:0 0 6px}
.ref{display:grid;grid-template-columns:26px 1fr;gap:16px;align-items:start;
padding:18px 18px 17px;margin:0 -18px;border-bottom:1px solid var(--rule-soft);
text-decoration:none;color:var(--ink);
transition:background .26s var(--ease),border-color .3s var(--ease)}
.ref .ic{margin-top:4px}
.ref .ic svg{width:21px;height:21px;display:block;fill:none;stroke:var(--muted);stroke-width:1.5;
stroke-linecap:round;stroke-linejoin:round;
transition:stroke .26s var(--ease),transform .34s var(--ease)}
.ref .rb{display:block;transition:transform .34s var(--ease)}
/* a quiet tint and an edge, not a hard flip to black: a full inversion on every row
strobes the page as the pointer travels down the list */
.ref{position:relative}
.ref::before{content:"";position:absolute;left:0;top:6px;bottom:6px;width:2px;background:var(--accent);
transform:scaleY(0);transform-origin:top;transition:transform .26s var(--ease)}
.ref:hover{background:var(--surface);border-color:var(--rule)}
.ref:hover::before{transform:scaleY(1)}
.ref:hover .rb{transform:translateX(7px)}
.ref:hover .ic svg{stroke:var(--accent);transform:translateX(2px)}
.ref:hover .rt{color:var(--accent)}
.rk{font:700 .625rem/1 var(--sans);letter-spacing:.14em;text-transform:uppercase;color:var(--accent);
transition:color .26s var(--ease)}
.rt{display:block;margin:7px 0 4px;font:600 1.125rem/1.4 var(--serif);letter-spacing:-.012em;
transition:color .26s var(--ease)}
.rd{display:block;color:var(--muted);font-size:.9375rem;line-height:1.6;font-style:italic;
transition:color .26s var(--ease)}
.prose{margin:44px 0 0;max-width:none;font-size:1.0625rem;color:var(--ink)}
.prose p{margin:0 0 15px;text-align:justify;hyphens:auto;-webkit-hyphens:auto;text-wrap:pretty}
.prose em{color:var(--muted)}
/* author */
.author{display:flex;gap:18px;align-items:flex-start;margin-top:34px;padding-top:26px;
border-top:1px solid var(--ink)}
.author img{width:77px;height:77px;border-radius:50%;flex:none;object-fit:cover;
border:1px solid var(--rule);filter:grayscale(1);transition:filter .45s var(--ease),transform .45s var(--ease)}
.author:hover img{filter:grayscale(0);transform:scale(1.04)}
.author .nm{font:700 1.125rem/1.3 var(--serif);letter-spacing:-.015em;white-space:nowrap}
.author .role{color:var(--muted);font-style:italic;font-size:.9375rem;margin:2px 0 7px}
.byline{margin-top:0;color:var(--muted);font:400 .9375rem/1.95 var(--serif)}
.byline .who{color:var(--ink);font-weight:600;font-style:normal;white-space:nowrap}
.links{display:flex;flex-wrap:wrap;gap:6px 16px;margin:0;padding:0;list-style:none}
.links a{display:inline-flex;align-items:center;gap:7px;color:var(--muted);text-decoration:none;
font:500 .8125rem/1 var(--sans);letter-spacing:.005em;padding:5px 0;
transition:color .24s var(--ease)}
.links a svg{width:15px;height:15px;flex:none;fill:currentColor;opacity:.75;overflow:visible;
transition:opacity .24s var(--ease),transform .3s var(--ease)}
.links a span{border-bottom:1px solid transparent;transition:border-color .24s var(--ease)}
.links a{border-radius:4px;padding:5px 7px;margin:0 -7px}
.links a:hover{color:var(--accent)}
.links a:hover svg{opacity:1;transform:translateY(-1px)}
.links a:hover span{border-color:var(--accent)}
.byline a{color:var(--muted);text-decoration:none;white-space:nowrap;
background-image:linear-gradient(var(--accent),var(--accent));
background-size:0% 1px;background-position:0 94%;background-repeat:no-repeat;
transition:background-size .32s var(--ease),color .28s var(--ease)}
.byline a:hover{color:var(--accent);background-size:100% 1px}
.byline .sep{opacity:.4;padding:0 7px}
.byline a[href^="mailto"]{white-space:nowrap}
.note{margin-top:4px;color:var(--muted);font-size:.875rem;font-style:italic}
.sr{position:absolute;width:1px;height:1px;overflow:hidden;clip:rect(0 0 0 0);white-space:nowrap}
@media (max-width:760px){
body{font-size:16px}
.wrap{padding:28px 18px 56px}
header{margin-bottom:30px}
.drop{min-height:min(44vh,330px);padding:42px 18px;gap:18px}
.drop::before{inset:18px}
.crop i{width:11px;height:11px}
.crop i:nth-child(1),.crop i:nth-child(2){top:10px}
.crop i:nth-child(3),.crop i:nth-child(4){bottom:10px}
.crop i:nth-child(1),.crop i:nth-child(3){left:10px}
.crop i:nth-child(2),.crop i:nth-child(4){right:10px}
.bar{gap:8px}
.bar .btn{flex:1 1 auto;min-width:72px}
.bar #newBtn,.bar #dl{flex:1 1 44%}
.bar .sp{display:none}
.info{margin-top:56px}
.ref:hover .rb{transform:none}
.ref{grid-template-columns:22px 1fr;gap:13px;padding-left:0;padding-right:0;margin:0}
.prose p{text-align:left;hyphens:manual}
.author{gap:14px}
.author img{width:65px;height:65px}
}
@media (max-width:420px){
h1{font-size:1.9rem}
.lede{font-size:1rem;margin-bottom:28px}
}
@media (prefers-reduced-motion:reduce){
*,*::before,*::after{animation:none!important;transition:none!important}
.fade{opacity:1;transform:none}
.working .stage img{filter:blur(2.5px)}
}
</style>
</head>
<body>
<div class="wrap">
<header class="fade">
<div class="mark">Deblur<i>.</i></div>
<div class="hdr-right">
<div class="status" id="status" aria-live="polite"><span class="dot wait" id="dot"></span><span id="statusText">Connecting</span></div>
<button class="themebtn" id="themeBtn" type="button" aria-label="Switch between dark and light theme" title="Switch theme">&#9789;</button>
</div>
</header>
<div class="masthead fade">
<h1>Motion blur, <em>removed</em>.</h1>
<p class="lede">Full resolution in, uncompressed out.</p>
</div>
<div class="card fade">
<span class="crop" aria-hidden="true"><i></i><i></i><i></i><i></i></span>
<label class="drop" id="drop">
<div class="lens" aria-hidden="true"></div>
<span class="btn primary"><span>Choose a photo</span></span>
<div class="rule-x" aria-hidden="true"></div>
<p>or drop one here</p>
<input class="sr" type="file" id="file" accept="image/*,.heic,.heif">
</label>
<div class="viewer" id="viewer" aria-label="Before and after comparison">
<div class="stage" id="stage">
<img id="imgAfter" alt="Restored photo" draggable="false">
<img id="imgBefore" alt="Original photo" draggable="false">
<canvas id="map" aria-hidden="true"></canvas>
</div>
<div class="divider" id="divider"><div class="knob" id="knob" role="slider" tabindex="0"
aria-label="Comparison position" aria-valuemin="0" aria-valuemax="100" aria-valuenow="50">&#8596;</div></div>
<span class="tag l" id="tagL">BEFORE</span><span class="tag r" id="tagR">AFTER</span>
</div>
</div>
<div class="progress" id="progress"><i id="progressFill"></i></div>
<div class="steps" id="steps">
<span class="st" id="stUp"><b></b>Upload</span><span class="gap"></span>
<span class="st" id="stRun"><b></b>Restore</span><span class="gap"></span>
<span class="st" id="stDone"><b></b>Ready</span>
</div>
<p class="msg" id="msg"></p>
<div class="bar" id="bar">
<button class="btn" id="zoomOut" aria-label="Zoom out"><span>&minus;</span></button>
<button class="btn" id="zoomIn" aria-label="Zoom in"><span>+</span></button>
<button class="btn" id="mapBtn" aria-pressed="false"><span>What changed</span></button>
<span class="sp"></span>
<button class="btn" id="newBtn"><span>New photo</span></button>
<a class="btn primary" id="dl" download="restored.png" href="#"><span>Download PNG</span></a>
</div>
<section class="info fade">
<div class="prose">
<p>When your hand moves while the shutter is open, every point of light is recorded as a
short streak instead of a point. Sharpening cannot put that back, it only raises contrast
on whatever survived. This uses a neural network trained on 7,400 pairs of the same scene,
captured once shaken and once still, so it has learned what the smeared detail is supposed
to look like and reconstructs it.</p>
<p><em>The pairs all come from one phone, so photos from other cameras, shots that were out
of focus rather than shaken, and very dark scenes may not improve.</em></p>
</div>
<h2 style="margin-top:44px">Research</h2>
<div class="hr"></div>
<a class="ref" href="https://arxiv.org/abs/2605.05990">
<span class="ic"><svg viewBox="0 0 24 24" aria-hidden="true"><path d="M6 2.75h7.5L18.25 7.5v13.75H6z"/><path d="M13.25 2.75V7.5h5"/><path d="M9 12.5h6M9 15.5h6M9 18h3.5"/></svg></span>
<span class="rb">
<span class="rk">Paper</span>
<span class="rt">iPhoneBlur: A Difficulty-Stratified Benchmark for Consumer Device Motion Deblurring</span>
<span class="rd">Abdullah Al Shafi, Kazi Saeed Alam. arXiv, 2026.</span>
</span>
</a>
<a class="ref" href="https://huggingface.co/datasets/Shafi99/iPhoneBlur">
<span class="ic"><svg viewBox="0 0 24 24" aria-hidden="true"><ellipse cx="12" cy="6" rx="6.75" ry="2.75"/><path d="M5.25 6v5.5c0 1.52 3.02 2.75 6.75 2.75s6.75-1.23 6.75-2.75V6"/><path d="M5.25 11.5V17c0 1.52 3.02 2.75 6.75 2.75s6.75-1.23 6.75-2.75v-5.5"/></svg></span>
<span class="rb">
<span class="rk">Dataset</span>
<span class="rt">iPhoneBlur</span>
<span class="rd">7,400 real blur and sharp pairs from hand-held video, stratified into easy, medium and hard.</span>
</span>
</a>
<a class="ref" href="https://huggingface.co/Shafi99/iPhoneBlur_Finetune_Models">
<span class="ic"><svg viewBox="0 0 24 24" aria-hidden="true"><circle cx="5.5" cy="12" r="2.25"/><circle cx="17" cy="6.5" r="2.25"/><circle cx="17" cy="17.5" r="2.25"/><path d="m7.6 11 7.3-3.4M7.6 13l7.3 3.4"/></svg></span>
<span class="rb">
<span class="rk">Model</span>
<span class="rt">Fine-tuned restoration weights</span>
<span class="rd">The checkpoint behind this page, with the other baselines from the paper.</span>
</span>
</a>
<a class="ref" href="https://github.com/C-loud-Nine/iPhoneBlur">
<span class="ic"><svg viewBox="0 0 24 24" aria-hidden="true"><path d="m8.5 8.5-4 3.5 4 3.5M15.5 8.5l4 3.5-4 3.5M13.5 5.5l-3 13"/></svg></span>
<span class="rb">
<span class="rk">Code</span>
<span class="rt">Benchmark and training code</span>
<span class="rd">Dataset construction, difficulty stratification and evaluation.</span>
</span>
</a>
<div class="author">
<img src="author.jpg" alt="Abdullah Al Shafi" width="77" height="77" loading="lazy">
<div>
<div class="nm">Abdullah Al Shafi</div>
<p class="role">Computer vision, generative models, medical image analysis</p>
<ul class="links">
<li><a href="https://c-loud-nine.github.io"><svg viewBox="0 0 24 24" aria-hidden="true" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round"><path d="M12 2.6a9.4 9.4 0 1 0 0 18.8 9.4 9.4 0 0 0 0-18.8zm0 0c-2.6 2.2-4 5.5-4 9.4s1.4 7.2 4 9.4c2.6-2.2 4-5.5 4-9.4s-1.4-7.2-4-9.4zM3 12h18"/></svg><span>Website</span></a></li>
<li><a href="https://scholar.google.com/citations?user=ArbB-roAAAAJ"><svg viewBox="0 0 24 24" aria-hidden="true"><path d="M5.242 13.769 0 9.5 12 0l12 9.5-5.242 4.269C17.548 11.249 14.978 9.5 12 9.5s-5.548 1.749-6.758 4.269zM12 10a7 7 0 1 0 0 14 7 7 0 0 0 0-14z"/></svg><span>Scholar</span></a></li>
<li><a href="https://github.com/C-loud-Nine"><svg viewBox="0 0 24 24" aria-hidden="true"><path d="M12 .297c-6.63 0-12 5.373-12 12 0 5.303 3.438 9.8 8.205 11.385.6.113.82-.258.82-.577 0-.285-.01-1.04-.015-2.04-3.338.724-4.042-1.61-4.042-1.61C4.422 18.07 3.633 17.7 3.633 17.7c-1.087-.744.084-.729.084-.729 1.205.084 1.838 1.236 1.838 1.236 1.07 1.835 2.809 1.305 3.495.998.108-.776.417-1.305.76-1.605-2.665-.3-5.466-1.332-5.466-5.93 0-1.31.465-2.38 1.235-3.22-.135-.303-.54-1.523.105-3.176 0 0 1.005-.322 3.3 1.23.96-.267 1.98-.399 3-.405 1.02.006 2.04.138 3 .405 2.28-1.552 3.285-1.23 3.285-1.23.645 1.653.24 2.873.12 3.176.765.84 1.23 1.91 1.23 3.22 0 4.61-2.805 5.625-5.475 5.92.42.36.81 1.096.81 2.22 0 1.606-.015 2.896-.015 3.286 0 .315.21.69.825.57C20.565 22.092 24 17.592 24 12.297c0-6.627-5.373-12-12-12"/></svg><span>GitHub</span></a></li>
<li><a href="https://huggingface.co/Shafi99"><svg viewBox="0 0 24 24" aria-hidden="true"><circle cx="12" cy="12" r="9" fill="none" stroke="currentColor" stroke-width="1.7"/><circle cx="9" cy="10" r="1.15"/><circle cx="15" cy="10" r="1.15"/><path d="M8.2 14.2a4.4 4.4 0 0 0 7.6 0" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round"/></svg><span>Hugging Face</span></a></li>
<li><a href="https://www.linkedin.com/in/shafi-abdullah-0955a0293/"><svg viewBox="0 0 24 24" aria-hidden="true"><path d="M20.447 20.452h-3.554v-5.569c0-1.328-.027-3.037-1.852-3.037-1.853 0-2.136 1.445-2.136 2.939v5.667H9.351V9h3.414v1.561h.046c.477-.9 1.637-1.85 3.37-1.85 3.601 0 4.267 2.37 4.267 5.455v6.286zM5.337 7.433a2.062 2.062 0 1 1 0-4.125 2.062 2.062 0 0 1 0 4.125zm1.782 13.019H3.555V9h3.564v11.452zM22.225 0H1.771C.792 0 0 .774 0 1.729v20.542C0 23.227.792 24 1.771 24h20.451C23.2 24 24 23.227 24 22.271V1.729C24 .774 23.2 0 22.222 0h.003z"/></svg><span>LinkedIn</span></a></li>
<li><a href="https://www.researchgate.net/profile/Abdullah-Shafi-11"><svg viewBox="0 0 24 24" aria-hidden="true"><rect x="3" y="3" width="18" height="18" rx="4" fill="none" stroke="currentColor" stroke-width="1.7"/><text x="12" y="16" text-anchor="middle" font-family="Inter,sans-serif" font-size="9" font-weight="700" fill="currentColor">RG</text></svg><span>ResearchGate</span></a></li>
<li><a href="https://orcid.org/0009-0005-5114-6354"><svg viewBox="0 0 24 24" aria-hidden="true"><path d="M12 0C5.372 0 0 5.372 0 12s5.372 12 12 12 12-5.372 12-12S18.628 0 12 0zM7.369 4.378c.525 0 .947.431.947.947s-.422.947-.947.947a.95.95 0 0 1-.947-.947c0-.516.422-.947.947-.947zm-.722 3.038h1.444v10.041H6.647V7.416zm3.562 0h3.9c3.712 0 5.344 2.653 5.344 5.025 0 2.578-2.016 5.025-5.325 5.025h-3.919V7.416zm1.444 1.303v7.444h2.297c2.359 0 3.588-1.444 3.588-3.722 0-2.016-1.091-3.722-3.588-3.722h-2.297z"/></svg><span>ORCID</span></a></li>
<li><a href="mailto:abdullah.shafi99@gmail.com"><svg viewBox="0 0 24 24" aria-hidden="true"><rect x="2.5" y="5" width="19" height="14" rx="2" fill="none" stroke="currentColor" stroke-width="1.7"/><path d="m3.5 7 8.5 6 8.5-6" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"/></svg><span>Email</span></a></li>
</ul>
</div>
</div>
<p class="note">Photos are processed in memory and never stored.</p>
</section>
</div>
<script>
// Static page, hostable anywhere. The model runs on a shared GPU behind this endpoint,
// which allows cross-origin calls.
const SPACE = "https://shafi99-deblur-engine.hf.space";
const FN = "restore_image";
const $ = id => document.getElementById(id);
const els = {drop:$("drop"),file:$("file"),viewer:$("viewer"),stage:$("stage"),before:$("imgBefore"),after:$("imgAfter"),
map:$("map"),divider:$("divider"),knob:$("knob"),bar:$("bar"),msg:$("msg"),progress:$("progress"),fill:$("progressFill"),
dl:$("dl"),dot:$("dot"),status:$("status"),statusText:$("statusText"),mapBtn:$("mapBtn"),
steps:$("steps"),stUp:$("stUp"),stRun:$("stRun"),stDone:$("stDone")};
const CALM = matchMedia("(prefers-reduced-motion: reduce)").matches;
// Tile grid matches the server: 512 px tiles stepping 416 px, so the count shown is real.
function tilesAlong(d){ return d <= 512 ? 1 : Math.ceil((d - 512) / 416) + 1; }
(function(){
const root = document.documentElement, btn = $("themeBtn");
const meta = document.querySelector('meta[name="theme-color"]');
const paint = () => {
const light = root.dataset.theme === "light";
btn.innerHTML = light ? "&#9728;" : "&#9789;"; // sun when light, moon when dark
btn.setAttribute("aria-pressed", String(light));
if(meta) meta.setAttribute("content", light ? "#f9f8f6" : "#17181b");
};
btn.addEventListener("click", () => {
root.dataset.theme = root.dataset.theme === "light" ? "dark" : "light";
try{ localStorage.setItem("theme", root.dataset.theme); }catch(e){}
paint();
});
paint();
})();
function setStatus(kind, text){
els.dot.className = "dot " + kind;
els.status.className = "status is-" + (kind === "wait" ? "wait" : kind === "ok" ? "ok" : "bad");
els.statusText.textContent = text;
}
// Three steps rather than a spinner, so the wait has a shape.
function step(which){
const order = ["up", "run", "done"], el = {up:els.stUp, run:els.stRun, done:els.stDone};
els.steps.classList.toggle("on", !!which);
order.forEach((k, i) => {
el[k].className = "st" + (k === which ? " now" : (which && i < order.indexOf(which) ? " done" : ""));
});
}
function human(sec){ return sec < 60 ? Math.round(sec) + "s" : Math.floor(sec/60) + "m " + Math.round(sec%60) + "s"; }
function say(text, err){ els.msg.textContent = text || ""; els.msg.classList.toggle("err", !!err); }
/* ---------- wake the model ---------- */
let serverReady = false;
async function warm(){
setStatus("wait", "Connecting");
try{
const r = await fetch(SPACE + "/gradio_api/info", {cache:"no-store"});
if(!r.ok) throw new Error();
serverReady = true; setStatus("ok", "Ready");
}catch(e){ setStatus("bad", "Offline"); }
}
/* ---------- viewer: split, zoom, pan ---------- */
const V = {W:0,H:0,vw:0,vh:0,s0:1,z:1,tx:0,ty:0,p:.5};
function layout(){
const rect = els.viewer.getBoundingClientRect();
V.vw = rect.width || els.viewer.parentElement.clientWidth;
V.vh = Math.min(innerHeight*0.74, V.vw*V.H/V.W);
els.viewer.style.height = V.vh + "px";
V.s0 = Math.min(V.vw/V.W, V.vh/V.H);
}
function clampPan(){
const S = V.s0*V.z, w = V.W*S, h = V.H*S;
V.tx = w <= V.vw ? (V.vw-w)/2 : Math.min(0, Math.max(V.vw-w, V.tx));
V.ty = h <= V.vh ? (V.vh-h)/2 : Math.min(0, Math.max(V.vh-h, V.ty));
}
function render(){
clampPan();
const S = V.s0*V.z;
els.stage.style.width = V.W+"px"; els.stage.style.height = V.H+"px";
els.stage.style.transform = `translate(${V.tx}px,${V.ty}px) scale(${S})`;
els.before.style.clipPath = `inset(0 ${(1-V.p)*100}% 0 0)`;
const x = V.tx + V.p*V.W*S;
els.divider.style.transform = `translateX(${x}px)`;
els.knob.setAttribute("aria-valuenow", Math.round(V.p*100));
els.viewer.classList.toggle("zoomed", V.z > 1.01);
}
function fit(){ V.z = 1; V.tx = V.ty = 0; layout(); render(); }
// Wipe the result in once, so the change reads without needing a caption.
function revealSplit(to){
if(CALM){ V.p = to; render(); return; }
const start = performance.now(), from = V.p, ms = 700;
(function step(now){
const k = Math.min(1, (now - start)/ms), e = 1 - Math.pow(1 - k, 3);
V.p = from + (to - from)*e; render();
if(k < 1) requestAnimationFrame(step);
})(start);
}
function zoomAt(f, cx, cy){
const S = V.s0*V.z, ns = Math.min(V.s0*8, Math.max(V.s0, S*f));
V.tx = cx - (cx - V.tx)*(ns/S); V.ty = cy - (cy - V.ty)*(ns/S); V.z = ns/V.s0; render();
}
const ptrs = new Map(); let mode = null, last = null;
function local(e){ const r = els.viewer.getBoundingClientRect(); return {x:e.clientX-r.left, y:e.clientY-r.top}; }
function setSplit(x){ const S=V.s0*V.z; V.p = Math.min(1, Math.max(0, (x - V.tx)/(V.W*S))); render(); }
els.viewer.addEventListener("pointerdown", e=>{
els.viewer.setPointerCapture(e.pointerId);
const pt = local(e); ptrs.set(e.pointerId, pt);
if(ptrs.size === 2){ mode = "pinch"; last = null; return; }
const dx = V.tx + V.p*V.W*V.s0*V.z;
if(Math.abs(pt.x - dx) < 30){ mode = "split"; }
else if(V.z > 1.01){ mode = "pan"; last = pt; }
else { mode = "split"; setSplit(pt.x); }
});
els.viewer.addEventListener("pointermove", e=>{
if(!ptrs.has(e.pointerId)) return;
const pt = local(e), prev = ptrs.get(e.pointerId); ptrs.set(e.pointerId, pt);
if(mode === "split") setSplit(pt.x);
else if(mode === "pan" && last){ V.tx += pt.x-last.x; V.ty += pt.y-last.y; last = pt; render(); }
else if(mode === "pinch" && ptrs.size === 2){
const oid = [...ptrs.keys()].find(i => i !== e.pointerId), other = ptrs.get(oid);
const d0 = Math.hypot(prev.x-other.x, prev.y-other.y), d1 = Math.hypot(pt.x-other.x, pt.y-other.y);
if(d0 > 0) zoomAt(d1/d0, (pt.x+other.x)/2, (pt.y+other.y)/2);
}
});
function up(e){ ptrs.delete(e.pointerId); if(ptrs.size < 2 && mode === "pinch") mode = null; if(!ptrs.size) mode = null; }
els.viewer.addEventListener("pointerup", up); els.viewer.addEventListener("pointercancel", up);
els.viewer.addEventListener("wheel", e=>{ e.preventDefault(); const p = local(e); zoomAt(e.deltaY < 0 ? 1.15 : 1/1.15, p.x, p.y); }, {passive:false});
els.viewer.addEventListener("dblclick", e=>{ const p = local(e); if(V.z > 1.01) fit(); else zoomAt(3, p.x, p.y); });
els.knob.addEventListener("keydown", e=>{
if(e.key === "ArrowLeft"){ V.p = Math.max(0, V.p-.03); render(); e.preventDefault(); }
if(e.key === "ArrowRight"){ V.p = Math.min(1, V.p+.03); render(); e.preventDefault(); }
});
$("zoomIn").onclick = ()=>zoomAt(1.5, V.vw/2, V.vh/2);
$("zoomOut").onclick = ()=>zoomAt(1/1.5, V.vw/2, V.vh/2);
addEventListener("resize", ()=>{ if(V.W) { layout(); render(); } });
/* ---------- recovery map, computed in the browser ---------- */
// Earlier this measured fine detail in the original, which confuses "smooth" with
// "blurred": an empty sky scored the same as a smeared face. This measures what the
// model actually changed instead, so flat areas it left alone stay clear and the places
// it rebuilt detail light up. Pooled with an integral image and normalised against a
// high percentile, so one bright edge cannot own the scale.
function drawMap(){
const c = els.map;
const k = Math.min(1, 440 / Math.max(V.W, V.H));
const w = Math.max(24, Math.round(V.W * k)), h = Math.max(24, Math.round(V.H * k));
c.width = w; c.height = h;
const g = c.getContext("2d", {willReadFrequently:true});
const grab = img => {
const t = document.createElement("canvas");
t.width = w; t.height = h;
const tg = t.getContext("2d", {willReadFrequently:true});
tg.drawImage(img, 0, 0, w, h);
return tg.getImageData(0, 0, w, h).data;
};
let A, B;
try{ A = grab(els.before); B = grab(els.after); }
catch(e){ return false; } // a tainted canvas cannot be read back
const n = w * h;
const D = new Float32Array(n);
for(let i = 0; i < n; i++){
const j = i * 4;
const la = .2126*A[j] + .7152*A[j+1] + .0722*A[j+2];
const lb = .2126*B[j] + .7152*B[j+1] + .0722*B[j+2];
const d = lb - la;
D[i] = d * d;
}
const W1 = w + 1, I = new Float64Array(W1 * (h + 1));
for(let y = 0; y < h; y++){
let row = 0;
for(let x = 0; x < w; x++){
row += D[y*w + x];
I[(y+1)*W1 + x+1] = I[y*W1 + x+1] + row;
}
}
const r = Math.max(2, Math.round(Math.min(w, h) / 90));
const S = new Float32Array(n);
for(let y = 0; y < h; y++) for(let x = 0; x < w; x++){
const x0 = Math.max(0, x-r), y0 = Math.max(0, y-r);
const x1 = Math.min(w-1, x+r), y1 = Math.min(h-1, y+r);
const sum = I[(y1+1)*W1 + x1+1] - I[y0*W1 + x1+1] - I[(y1+1)*W1 + x0] + I[y0*W1 + x0];
S[y*w + x] = Math.sqrt(sum / ((x1-x0+1) * (y1-y0+1)));
}
const sorted = Float32Array.from(S).sort();
const top = sorted[Math.floor(n * .97)] || 1;
const css = getComputedStyle(document.documentElement);
const hex = (css.getPropertyValue("--accent").trim() || "#ae5038").replace("#", "");
const cr = parseInt(hex.slice(0,2),16), cg = parseInt(hex.slice(2,4),16), cb = parseInt(hex.slice(4,6),16);
const out = g.createImageData(w, h);
for(let i = 0; i < n; i++){
const changed = Math.pow(Math.min(1, S[i] / top), .8);
out.data[i*4] = cr;
out.data[i*4+1] = cg;
out.data[i*4+2] = cb;
out.data[i*4+3] = Math.round(changed * 215); // untouched areas stay clear
}
g.putImageData(out, 0, 0);
return true;
}
els.mapBtn.onclick = ()=>{
const on = els.mapBtn.getAttribute("aria-pressed") !== "true";
if(on && !drawMap()){ say("The map is not available for this photo.", true); return; }
els.mapBtn.setAttribute("aria-pressed", on);
els.map.classList.toggle("on", on);
say(on ? "Tinted areas are where the model rebuilt detail." : "");
};
/* ---------- show a before/after pair ---------- */
async function showPair(beforeUrl, afterUrl, dlName){
els.before.src = beforeUrl; els.after.src = afterUrl;
await Promise.all([els.before.decode(), els.after.decode()]);
V.W = els.before.naturalWidth; V.H = els.before.naturalHeight; V.p = 1;
els.drop.style.display = "none"; els.viewer.style.display = "block"; els.bar.style.display = "flex";
els.viewer.classList.remove("working");
els.dl.href = afterUrl; els.dl.download = dlName || "restored.png";
els.mapBtn.setAttribute("aria-pressed","false"); els.map.classList.remove("on");
fit();
revealSplit(.5);
}
function resetToPicker(){
els.viewer.style.display = "none"; els.bar.style.display = "none"; els.drop.style.display = "flex";
els.progress.style.display = "none"; step(null); say(""); els.file.value = "";
releaseUrls();
}
$("newBtn").onclick = resetToPicker;
// The restored file lives on another origin, and browsers ignore the download attribute
// across origins: the click would just navigate to the image. Fetch it and save a blob instead.
els.dl.addEventListener("click", async e => {
const href = els.dl.getAttribute("href");
if(!href || href === "#"){ e.preventDefault(); return; }
if(new URL(href, location.href).origin === location.origin) return;
e.preventDefault();
const name = els.dl.getAttribute("download") || "restored.png";
const previous = els.msg.textContent;
say("Fetching full size");
try{
const r = await fetch(href);
if(!r.ok) throw new Error();
const url = URL.createObjectURL(await r.blob());
const a = document.createElement("a");
a.href = url; a.download = name;
document.body.appendChild(a); a.click(); a.remove();
setTimeout(() => URL.revokeObjectURL(url), 30000);
say(previous);
}catch(err){
say("Could not fetch the full size file.", true);
}
});
/* ---------- photo -> GPU -> full resolution back ---------- */
// The original file is sent untouched: no canvas, no re-encode, so the model sees exactly
// what the camera produced and the photo keeps its own resolution.
function measure(file){
return new Promise((resolve, reject) => {
const url = URL.createObjectURL(file), img = new Image();
img.onload = () => resolve({url, w: img.naturalWidth, h: img.naturalHeight});
img.onerror = () => { URL.revokeObjectURL(url); reject(new Error("That file is not an image the browser can open.")); };
img.src = url;
});
}
function upload(file, onProgress){
return new Promise((resolve, reject) => {
const fd = new FormData(); fd.append("files", file);
const x = new XMLHttpRequest();
x.open("POST", SPACE + "/gradio_api/upload"); x.timeout = 600000;
const began = performance.now();
// the estimate comes from the bytes actually moved so far, not a guess
x.upload.onprogress = e => {
if(!e.lengthComputable) return;
const secs = (performance.now() - began) / 1000;
const left = secs > .8 && e.loaded ? (e.total - e.loaded) / (e.loaded / secs) : null;
onProgress(e.loaded / e.total, left, e.total);
};
x.onload = () => {
if(x.status !== 200) return reject(new Error("The upload was refused. Try a smaller photo."));
try{ resolve(JSON.parse(x.responseText)[0]); }
catch(e){ reject(new Error("The server sent something unexpected.")); }
};
x.onerror = () => reject(new Error("Could not reach the server. Check your connection."));
x.ontimeout = () => reject(new Error("The upload took too long."));
x.send(fd);
});
}
// Report the real cause rather than blaming the photo for everything.
function explain(raw){
let detail = "";
try{ const p = JSON.parse(raw); detail = (p && (p.error || p.title)) || ""; }catch(e){}
const text = String(detail);
// a damaged file fails inside the server's own decoding, before our code runs, so the
// raw message mentions "preprocess input component" and means nothing to anyone
if(/preprocess|could not preprocess|decode/i.test(text))
return "That file could not be read as an image. It may be damaged or in a format the server does not support.";
if(/quota|exceeded|runs limit/i.test(text)) return "The shared GPU is busy. Try again in a moment.";
if(/memory|out of memory|oom/i.test(text)) return "That photo was too large. Try under 40 megapixels.";
if(/timeout|timed out/i.test(text)) return "That took too long. A smaller photo should work.";
return text ? text.split(" - ")[0].trim() : "The server could not finish that photo.";
}
// Gradio answers with a stream of events rather than one response, so read it as it arrives.
async function runJob(serverPath){
const start = await fetch(SPACE + "/gradio_api/call/" + FN, {
method: "POST", headers: {"Content-Type": "application/json"},
body: JSON.stringify({data: [{path: serverPath, meta: {_type: "gradio.FileData"}}]})
});
if(!start.ok) throw new Error("The server would not start the job.");
const {event_id} = await start.json();
const res = await fetch(SPACE + "/gradio_api/call/" + FN + "/" + event_id);
const reader = res.body.getReader(), dec = new TextDecoder();
let buf = "", event = null;
for(;;){
const {value, done} = await reader.read();
if(done) break;
buf += dec.decode(value, {stream: true});
let nl;
while((nl = buf.indexOf("\n")) >= 0){
const line = buf.slice(0, nl).trim(); buf = buf.slice(nl + 1);
if(line.startsWith("event:")) event = line.slice(6).trim();
else if(line.startsWith("data:") && (event === "complete" || event === "error")){
if(event === "error") throw new Error(explain(line.slice(5)));
const out = JSON.parse(line.slice(5));
const files = (Array.isArray(out) ? out : [out]).map(f => f && (f.url || f.path)).filter(Boolean);
if(!files.length) throw new Error("The server returned nothing.");
return {preview: files[0], full: files[files.length - 1]};
}
}
}
throw new Error("The connection closed before the photo was ready.");
}
const temporaryUrls = [];
function revokeLater(url){ temporaryUrls.push(url); return url; }
function releaseUrls(){ while(temporaryUrls.length) URL.revokeObjectURL(temporaryUrls.pop()); }
let busy = false;
async function handleFile(file){
if(busy || !file) return; busy = true;
let timer;
try{
say(""); els.progress.style.display = "block"; els.progress.classList.remove("indet"); els.fill.style.width = "3%";
const {url: beforeUrl, w, h} = await measure(file);
els.before.src = beforeUrl; els.after.src = beforeUrl; await els.before.decode();
V.W = w; V.H = h; V.p = 1; els.drop.style.display = "none"; els.viewer.style.display = "block";
els.bar.style.display = "none"; els.viewer.classList.add("working"); fit();
const t0 = performance.now(); let phase = "up";
const tiles = tilesAlong(w) * tilesAlong(h);
step("up"); say("Uploading");
timer = setInterval(() => {
if(phase === "work")
say("Restoring " + tiles + (tiles > 1 ? " tiles" : " tile")
+ " \u00b7 " + human((performance.now()-t0)/1000));
}, 500);
const serverPath = await upload(file, (f, left, total) => {
els.fill.style.width = Math.round(3 + f*57) + "%";
if(f < 1)
say("Uploading " + (total/1e6).toFixed(1) + " MB"
+ (left != null ? " \u00b7 " + human(left) + " left" : ""));
if(f >= 1 && phase === "up"){ phase = "work"; els.progress.classList.add("indet"); step("run"); }
});
phase = "work"; els.progress.classList.add("indet"); step("run");
const {preview, full} = await runJob(serverPath);
els.progress.style.display = "none"; clearInterval(timer); step("done");
const name = (file.name.replace(/\.[^.]+$/, "") || "photo") + "-deblurred.png";
// Pull the preview through fetch and show it from a blob. Painting a cross-origin
// image into a canvas taints it, and the map then cannot read its own pixels back.
let shown = preview;
try{
const r = await fetch(preview);
if(r.ok){ revokeLater(shown = URL.createObjectURL(await r.blob())); }
}catch(e){ /* fall back to the remote url: the map will simply stay unavailable */ }
await showPair(beforeUrl, shown, name);
els.dl.href = full;
say(w + " × " + h + " · " + Math.round((performance.now()-t0)/1000) + "s");
serverReady = true; setStatus("ok", "Ready");
}catch(err){
clearInterval(timer); els.progress.style.display = "none"; step(null);
resetToPicker(); say(err.message || "Something went wrong.", true);
}finally{ busy = false; }
}
els.file.addEventListener("change", e => handleFile(e.target.files[0]));
["dragenter","dragover"].forEach(t => els.drop.addEventListener(t, e => { e.preventDefault(); els.drop.classList.add("over"); }));
["dragleave","drop"].forEach(t => els.drop.addEventListener(t, e => { e.preventDefault(); els.drop.classList.remove("over"); }));
els.drop.addEventListener("drop", e => handleFile(e.dataTransfer.files[0]));
if("serviceWorker" in navigator) addEventListener("load", () => navigator.serviceWorker.register("sw.js").catch(()=>{}));
warm();
</script>
</body>
</html>