Download index.html from Shafi99/Deblur: direct link, hf CLI and curl.
- Browser
- Download file 47.3 kB
-
https://huggingface.co/spaces/Shafi99/Deblur/resolve/main/index.html
- Command line
-
hf download hf://spaces/Shafi99/Deblur/index.html
-
curl -L -o index.html https://huggingface.co/spaces/Shafi99/Deblur/resolve/main/index.html
47.3 kB
| <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;transition:none} | |
| .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">☽</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">↔</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>−</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 ? "☀" : "☽"; // 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> | |