A Modern Image Optimization Workflow in ranger (with Rofi, ImageMagick & WebP)
Table of Contents ๐
For many power users, the terminal is not a limitation but a productivity multiplier. Yet image optimization is often one of the last steps still handled by heavyweight GUI tools.
This guide shows you how to build a fully terminal-based image optimization workflow around ranger, combining ImageMagick, modern image optimizers, and a Rofi-driven preset menu. The result is a fast, reproducible setup that scales from simple social media exports to high-quality WebP graphics โ without ever leaving your file manager.
Changelog
| Date | Change |
|---|---|
| 2026-01-09 | Initial Version: Created guide for image optimization workflow with Rofi presets |
1. Prerequisites
This guide assumes a Linux system with a keyboard-focused workflow. Ensure you have the following tools installed:
- ranger: The terminal file manager
- ImageMagick: The image processing backend
- pngquant: Lossy PNG optimization
- oxipng: Lossless PNG optimization
- rofi: Interactive preset menu
- libwebp: WebP support
| โน๏ธ NOTE ON IMAGEMAGICK VERSIONS |
This workflow supports both ImageMagick 6 ( |
1.1. Installation Examples
# Arch Linux
sudo pacman -S ranger imagemagick pngquant oxipng rofi libwebp
# Debian / Ubuntu
sudo apt update && sudo apt install ranger imagemagick pngquant oxipng rofi libwebp
2. Design Goals & Processing Strategy
Before diving into the code, it is important to understand the design principles behind this setup:
- Non-destructive: Original images are never modified.
- Preset-driven: No manual tweaking of parameters during daily use.
- Pixel-safe where required: Graphics and screenshots are handled differently from photos.
- Privacy-aware: Metadata is stripped by default.
- Fast access: One keybinding opens all optimization options.
2.1. Photos vs Graphics
A critical distinction is made between two image categories:
- Photos (JPEG/PNG): Lossy compression is acceptable. Target: small file size for web/social platforms.
- Graphics / UI / Screenshots: Pixel integrity matters. Target: lossless or near-lossless processing.
3. Implementation in commands.py
The code below should be added to your ranger configuration. It uses CommandLoader to run heavy processing in the background, keeping the ranger UI responsive.
3.1. Edit commands.py
Open your ranger configuration directory:
vim ~/.config/ranger/commands.py
3.2. Add the Optimization Logic
from ranger.api.commands import Command
import os
import shutil
import shlex
import subprocess
from ranger.core.loader import CommandLoader
class scale_base(Command):
""" Backend logic for image optimization """
def optimize(self, target_width, quality, mode='lossy', suffix='', force_fmt=None,
keep_meta=False, webp_lossless=False, do_colorspace=True):
cwd = self.fm.thisdir
marked_files = cwd.get_selection()
if not marked_files:
self.fm.notify("No files selected!", bad=True)
return
output_dir = cwd.path
image_exts = ('.jpg', '.jpeg', '.png', '.webp', '.tif', '.tiff')
images = [f for f in marked_files if f.path.lower().endswith(image_exts)]
if not images:
self.fm.notify("No images found!", bad=True)
return
magick_bin = 'magick' if shutil.which('magick') else ('convert' if shutil.which('convert') else None)
if not magick_bin:
self.fm.notify("Error: ImageMagick missing!", bad=True)
return
pngquant_bin = shutil.which('pngquant')
oxipng_bin = shutil.which('oxipng')
self.fm.notify(f"Starting optimization ({suffix})...")
for f in images:
base = os.path.basename(f.path)
name, raw_ext = os.path.splitext(base)
src_ext = raw_ext.lower()
out_ext = force_fmt if force_fmt else src_ext
final_suffix = suffix if suffix else f"_w{target_width}_{mode}"
out_name = f"{name}{final_suffix}{out_ext}"
dest = os.path.join(output_dir, out_name)
# Avoid overwriting
counter = 1
while os.path.exists(dest):
out_name = f"{name}{final_suffix}_{counter}{out_ext}"
dest = os.path.join(output_dir, out_name)
counter += 1
cmd_im = [magick_bin, f.path, '-auto-orient']
if target_width > 0:
cmd_im.extend(['-resize', f'{target_width}x>'])
if do_colorspace:
cmd_im.extend(['-colorspace', 'sRGB'])
do_strip_in_im = True
if keep_meta:
do_strip_in_im = False
elif out_ext == '.png' and mode == 'hq' and oxipng_bin:
do_strip_in_im = False
if do_strip_in_im:
cmd_im.append('-strip')
if out_ext in ['.jpg', '.jpeg']:
cmd_im.extend(['-quality', str(quality)])
if mode == 'lossy':
cmd_im.extend(['-sampling-factor', '4:2:0', '-interlace', 'Plane'])
cmd_im.append(dest)
self.fm.loader.add(CommandLoader(args=cmd_im, descr=f"JPG {mode}: {base}", read=True))
elif out_ext == '.webp':
cmd_im.extend(['-define', 'webp:method=6'])
if webp_lossless:
cmd_im.extend(['-define', 'webp:lossless=true'])
else:
cmd_im.extend(['-quality', str(quality)])
cmd_im.extend(['-define', 'webp:alpha-quality=90'])
cmd_im.append(dest)
self.fm.loader.add(CommandLoader(args=cmd_im, descr=f"WebP: {base}", read=True))
elif out_ext == '.png':
cmd_im.append(dest)
safe_im_cmd = " ".join(shlex.quote(arg) for arg in cmd_im)
full_cmd = safe_im_cmd
descr = f"PNG (IM): {base}"
if mode == 'lossy' and pngquant_bin:
cmd_pq = [pngquant_bin, '--force', '--skip-if-larger', '--speed', '3', '--quality=65-85', dest]
safe_pq_cmd = " ".join(shlex.quote(arg) for arg in cmd_pq)
full_cmd = f"{safe_im_cmd} && {safe_pq_cmd}"
descr = f"PNG Lossy: {base}"
elif mode == 'hq' and oxipng_bin:
cmd_oxi = [oxipng_bin, '-o', '2', '--strip', 'safe', dest]
safe_oxi_cmd = " ".join(shlex.quote(arg) for arg in cmd_oxi)
full_cmd = f"{safe_im_cmd} && {safe_oxi_cmd}"
descr = f"PNG HQ: {base}"
self.fm.loader.add(CommandLoader(args=['sh', '-c', full_cmd], descr=descr, read=True))
else:
cmd_im.append(dest)
self.fm.loader.add(CommandLoader(args=cmd_im, descr=f"Convert: {base}", read=True))
if True:
# --- PRESETS ---
class scale_web(scale_base):
def execute(self):
self.optimize(1920, 82, 'lossy', "_web")
class scale_web_xl(scale_base):
def execute(self):
self.optimize(2560, 82, 'lossy', "_webXL")
class scale_web_hq(scale_base):
def execute(self):
self.optimize(1920, 92, 'hq', "_webHQ")
class scale_blog(scale_base):
def execute(self):
self.optimize(1400, 82, 'lossy', "_blog")
class scale_thumb(scale_base):
def execute(self):
self.optimize(600, 75, 'lossy', "_thumb")
class scale_ig_feed(scale_base):
def execute(self):
self.optimize(1080, 85, 'lossy', "_ig")
class scale_ig_portrait(scale_base):
def execute(self):
self.optimize(1350, 85, 'lossy', "_ig4x5")
class scale_story(scale_base):
def execute(self):
self.optimize(1080, 85, 'lossy', "_story")
class scale_linkedin(scale_base):
def execute(self):
self.optimize(1200, 85, 'lossy', "_li")
class scale_meta_ads(scale_base):
def execute(self):
self.optimize(1200, 85, 'lossy', "_ads")
class scale_jpeg_email(scale_base):
def execute(self):
self.optimize(1280, 80, 'lossy', "_email", force_fmt='.jpg')
class scale_webp_hq(scale_base):
def execute(self):
self.optimize(1920, 85, 'lossy', "_web", force_fmt='.webp')
class scale_webp_lossless(scale_base):
def execute(self):
self.optimize(0, 0, 'hq', "_graphic", force_fmt='.webp', webp_lossless=True, do_colorspace=False)
class scale_webp_no_resize(scale_base):
def execute(self):
self.optimize(0, 82, 'lossy', "_webp", force_fmt='.webp')
class scale_png_graphic(scale_base):
def execute(self):
self.optimize(0, 0, 'hq', "_graphic", force_fmt='.png', do_colorspace=False)
class scale_strip_only(scale_base):
def execute(self):
self.optimize(0, 0, 'hq', "_stripped", keep_meta=False, do_colorspace=False)
# --- ROFI MENU ---
class image_optimization_menu(Command):
""" Rofi Menu for Image Optimization """
def execute(self):
menu_structure = [
("--- WEB ---", "true"),
("Web Standard (1920px)", "scale_web"),
("Web XL (2560px)", "scale_web_xl"),
("Web HQ (1920px)", "scale_web_hq"),
("Blog (1400px)", "scale_blog"),
("Thumbnail (600px)", "scale_thumb"),
("--- SOCIAL ---", "true"),
("IG Feed (1080px)", "scale_ig_feed"),
("IG Portrait (1350px)", "scale_ig_portrait"),
("IG Story/Reel (1080px)", "scale_story"),
("LinkedIn (1200px)", "scale_linkedin"),
("Meta Ads (1200px)", "scale_meta_ads"),
("JPEG Email (1280px)", "scale_jpeg_email"),
("--- MODERN/TOOLS ---", "true"),
("WebP HQ (1920px)", "scale_webp_hq"),
("WebP Lossless (Grafik)", "scale_webp_lossless"),
("WebP Convert (Original Size)", "scale_webp_no_resize"),
("PNG Graphic (Original, no sRGB)", "scale_png_graphic"),
("Strip Metadata Only", "scale_strip_only")
]
if not shutil.which('rofi'):
self.fm.notify("Rofi missing!", bad=True)
return
options_str = "\n".join([item[0] for item in menu_structure])
try:
p = subprocess.Popen(
['rofi', '-dmenu', '-p', 'Optimize', '-i', '-lines', str(len(menu_structure))],
stdin=subprocess.PIPE,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
text=True
)
stdout, _ = p.communicate(input=options_str)
selection = stdout.strip()
if not selection:
return
for label, cmd in menu_structure:
if label == selection:
if cmd != "true":
self.fm.execute_console(cmd)
break
except Exception as e:
self.fm.notify(f"Error: {e}", bad=True)
return
4. Creating Keyboard Shortcuts
Now, letโs map the interactive menu to a shortcut.
4.1. Edit rc.conf
Open your rc.conf:
vim ~/.config/ranger/rc.conf
4.2. Add the Mapping
# Image Optimization Menu
map oi image_optimization_menu
| Shortcut | Description |
|---|---|
| oi | Open Rofi menu to select image optimization preset |
5. Preset Overview
The workflow provides curated presets for real-world scenarios:
- Web Standard (1920px): Balanced compression for websites.
- Web XL (2560px): Extra-wide output for large screens.
- Web HQ (1920px): Minimal compression for high-quality portfolios.
- Blog (1400px): Cleaner sizing for article layouts.
- Thumbnail (600px): Small previews.
- Instagram Feed (1080px): Optimized for Instagramโs upload limits.
- IG Portrait (1350px): 4:5 portrait-friendly sizing.
- IG Story/Reel (1080px): Vertical-first exports.
- LinkedIn (1200px): Social sharing format.
- Meta Ads (1200px): Ad-friendly sizing.
- JPEG Email (1280px): Compatibility-first export.
- WebP HQ: Modern lossy format for superior size-to-quality ratio.
- WebP Lossless (Graphic): Lossless WebP for pixel-perfect graphics.
- WebP Convert (Original Size): Convert without resizing.
- PNG Graphic (Original, no sRGB): Preserve pixel values and skip sRGB conversion.
- Strip Metadata: Removes EXIF/GPS data without changing pixel values.
| ๐ก WHY ROFI? |
Rofi provides fuzzy search and instant feedback. It fits perfectly into window manager workflows and avoids the need to memorize dozens of separate keybindings. |
6. Practical Use Cases
6.1. Content Publishing
- Navigate to your image folder in ranger.
- Mark images with Space.
- Press
oiand select Web Standard. - Optimized files appear in the same folder as the originals.
6.2. Privacy Stripping
Before sharing photos online, mark them and run Strip Metadata Only to ensure no GPS or camera information is leaked.
7. Troubleshooting
7.1. Images appear rotated
The script uses -auto-orient before processing. This ensures that the orientation tag is respected even when metadata is stripped.
7.2. Colors look different
Web presets normalize images to sRGB. If you need to preserve a specific color profile, use the Strip Only or PNG Graphic presets which skip colorspace conversion.
8. Summary
With this integration, youโve transformed ranger into a high-performance image processing station:
- โ Batch processing of multiple images
- โ Searchable Rofi menu for all presets
- โ Background execution via CommandLoader
- โ Support for modern formats like WebP
- โ Optimized for web, social media, and privacy
This setup combines the simplicity of ranger with the industrial-grade power of ImageMagick, ensuring your images are always perfectly optimized for any platform.
9. Related Ranger Guides
- Ranger and fzf Integration: Search files at lightning speed
- Image Metadata Viewing with exiftool: View EXIF data before optimizing
- File Compression Workflow: Archive your optimized assets
- Advanced Media Preview Configuration: Better previews for WebP files





