Images look soft on your 4K or HiDPI screen? This fixes it.
At 200% display scaling, or on any Retina screen, the browser draws a 1125-pixel-wide image across 2250 physical pixels. It has to invent three out of every four pixels, and it uses a cheap filter to do it. That is the mushy, washed-out look you may have noticed.
This script redraws those images with a proper Lanczos3 filter on your GPU, at exactly the size they will appear, so the browser's own scaling never touches them.
There are real-world comparisons further down.
Built for reading manga, comics and webtoons. Works on any image, anywhere, and has features that help at any scaling, even 100%.
- Install Tampermonkey or Violentmonkey.
- Install the script.
- Open a page with a big image and press
Alt+Pto switch it on for that site. - Press
Alt+Hto see what it is doing.
The script starts switched off, so it never changes a page you did not ask it to. Alt+P is remembered per site: switch a site on once and it stays on; switch it off and it stays off. To have it on everywhere, set enabledOnStart: true in the script.
| Key | What it does |
|---|---|
Alt+P |
Switch on or off for this site. Remembered per site |
Alt+M |
Size: fill the width → biggest whole-number zoom → one image pixel per screen pixel |
Alt+Q |
Filter: Lanczos3 → nearest → browser. Same size each time, so you can compare |
Alt+H |
Show or hide the info overlay. Remembered per site |
Alt+G |
Show or hide the overlay's diagnostic rows (size, factor, status, memory) |
Alt + left clickon an image |
Show that one image at one image pixel per screen pixel |
Alt + right clickon an image |
Show that one image at twice its own resolution |
- Every shortcut can be changed at the bottom of the config, and the overlay shows whatever you set.
- On a Mac,
AltisOption. Shortcuts also work on non-Latin keyboard layouts. - Keys do nothing while you are typing in a text field.
- Click shortcuts work even when the site puts an invisible overlay over its pages. Any other click is left alone, so
Alt+ click on a link still downloads it. - Enlarging one image never moves the others sideways.
"Fit width" fills all the free width beside the image, right to the edges of the window. It stops at anything next to the image, such as a sidebar, a panel or text, and at any edge that would cut the image off. Nothing on the page moves sideways; content below moves down as the image grows.
So a site with a narrow centred column gets the full width instead of empty bars on both sides, and the image never slides under a site's side panel. If you would rather keep the site's own column, set fitWidth: 'container'.
To make switching filters and scrolling back instant, resampled images are kept in memory. On sites that take their image data back after loading, a copy of the original is kept too. That is up to 64 MB per tab. Fine for a handful of tabs, but it adds up with many image-heavy tabs open.
Easy ways to keep it in check:
- Only switch on the sites you actually read. A site that is off costs nothing.
- Press
Alt+Pon a site you are finished with. - Close tabs you are done with. Everything is freed straight away.
- Use a tab suspender such as The Marvelous Suspender, or Chrome's built-in Memory Saver. Both work well with this script.
Edit the CFG block at the top of the script.
| Setting | Default | What it does |
|---|---|---|
enabledOnStart |
false |
Start switched on for every site. Off means nothing happens until you press Alt+P |
hudOnStart |
true |
Show the overlay when a page opens. Alt+H changes it, remembered per site |
detailsOnStart |
false |
Show the overlay's diagnostic rows when a page opens. Alt+G toggles them until the page reloads |
minNaturalWidth / minNaturalHeight |
700 / 1066 | Leave smaller images alone: avatars, icons, banners. 700 is just wide enough for Webtoons' strips |
mode |
fit-width |
Size to start with |
quality |
lanczos3 |
Filter to start with |
fitWidth |
space |
What "fit width" fills: all the free width (space), the site's column (container), or the window (window) |
fitHeightToo |
false |
Fit the height as well, so a whole page fits on screen |
maxOutputPixels |
64M | Don't resample beyond this many output pixels |
blobBudget |
64 MB | Memory for keeping resampled images ready (see above) |
lazyMargin |
1.5 | How many screenfuls ahead to get images ready, so they are done before you scroll to them |
keyToggle, keyMode, keyQuality, keyOverlay, keyDetails |
Alt+P ... |
Keyboard shortcuts, like 'Ctrl+Shift+K' or 'F2'. '' turns one off |
clickNative, clickDouble |
Alt+LeftClick, Alt+RightClick |
Click shortcuts: modifiers plus LeftClick, RightClick or MiddleClick |
Chrome won't run any userscript on file:// pages until you enable Allow access to file URLs in chrome://extensions → Tampermonkey → Details.
Even then, Chrome keeps files on disk away from the GPU. You still get the sizing options and nearest, but lanczos3 falls back to the browser's own scaling and says so in the overlay. To get full quality on a folder of local images, serve it over http://localhost.
It runs on every site, but with @grant none and no external libraries. Nothing is sent anywhere and no third party is ever contacted. It reads images already on the page, redraws them on your GPU, and puts them back.
The only request it can make is re-reading an image the page has already loaded: the same URL, usually straight from cache.
It stores two small on/off flags per site, and only when you press the key:
crispImages.enabled.<host> Alt+P
crispImages.hud.<host> Alt+H
About 70 bytes. No cookies, no databases. @grant none means the userscript storage APIs aren't even available to it.
To limit it to particular sites, change @match.
You can use testkit/compare.html to compare the test images in the same folder, or an image of your own.
Text crop:
Line Art crop:
Because nearest-neighbour is only correct at whole-number zooms. At 2.276× (about what a 1125px page needs to fill a 2560px-wide screen), it doubles some rows of pixels and drops others. That is the uneven, staircased look in the middle column of the first comparison. Lanczos3 has no such restriction.
crisp-edges behaves exactly like pixelated in Chrome, and -webkit-optimize-contrast is an old alias that does nothing. There is no CSS keyword for a good filter, which is why this one runs on the GPU.
You don't have to take my word for it. testkit/shader-selftest.html runs the shipping shader against a separate reference implementation and prints the difference:
2.276x (fill width) mean|d|=0.057 max|d|=1
2.000x (integer) mean|d|=0.003 max|d|=1
1.138x mean|d|=0.080 max|d|=1
0.640x (downscale) mean|d|=0.201 max|d|=1
The two passes keep their in-between result in a half-float texture. An 8-bit one clips the overshoot Lanczos produces at hard edges, which costs about 25 levels of accuracy on exactly the high-contrast line art comics are made of.
- It swaps the image's
srcfor a resampled copy. A site that manages that element itself may fight it; after a few tries the script gives up on that image and leaves it to the browser.Alt+Pturns the script off for a site. - Images from another domain need CORS headers before the GPU can read them. Without those, the script sizes them but cannot resample them.
- GIFs are sized but not resampled, so an animation keeps moving. A resample is a single still frame. (Animated WebP and PNG cannot be told apart from still ones, so they are resampled and stop moving. Press
Alt+Qtobrowserif that matters.) - Very tall images, over about 16,000 pixels once enlarged (long webtoon strips), are left to the browser's scaling. That is the most the GPU or the image format can take in one piece. The overlay says so.
- The first time an image is drawn at a given size and filter costs a moment. Repeats come from memory.
- Where the GPU can't be used,
nearestfalls back to CSS. - Some phone GPUs claim to support the 16-bit working texture the filter uses between its two passes, then draw it wrong (GitHub issue #1). Since 4.1 the script checks that texture against a small reference resample before using it, and falls back to 8 bits if it fails. 8 bits is slightly less exact at hard edges, but correct. The overlay's status says which one is in use.
On most sites the script reads the image, resamples it, and puts the sharper version back. On a few, the browser won't let it read the image at all. When that happens the overlay says something like:
chrome bilinear — cross-origin, and the image host sends no CORS headers
This is a browser security rule, not a fault on anyone's part, and the script can't get around it from inside the page. The only fix would be asking you for extra permissions, which would make the script much more suspicious.
What still works on those sites
- Sizing: the image is laid out at the size you asked for.
nearest(Alt+Q): it's a CSS filter, so it never reads pixels.
What doesn't
- Lanczos3, which is the main thing the script is for.
If a site looks untouched and you're not sure why, press Alt+H, then Alt+G, and read the status line. It also says when an image was skipped for being too small.
Run __crispImages.report() in the browser console. It lists every image the script knows about and what it did with each one, which is the most useful thing to include in a bug report. __crispImages.memory() shows what the cache is holding, and __crispImages.layout(img) shows the width it is filling for an image.
For timings, paste __crispImages.trace = true into the console and reproduce the lag. Add the output to your bug report.
MIT - see LICENSE. © 2026 Igkor Bevzenidis.
Issues and pull requests: https://github.com/Ikkoru/crisp-images






