
Pixel Art Scaler
- 98 installs
- 178 repo stars
- Updated July 14, 2026
- erichowens/some_claude_skills
Upscale pixel art using edge-aware algorithms (EPX, hq2x, xBR) for retina displays.
About
Applies deterministic scaling algorithms that preserve pixel art aesthetics at 2x/3x/4x magnification. Compares EPX (fast), hq2x (smooth), and xBR (highest quality) approaches.
- Three algorithms: EPX (fastest), hq2x (high quality), xBR (best smoothing)
- Never adds new colors—only combines palette colors
Pixel Art Scaler by the numbers
- 98 all-time installs (skills.sh)
- Ranked #1,148 of 1,880 Design & UI/UX skills by installs in the Skillselion catalog
- Data as of Aug 4, 2026 (Skillselion catalog sync)
npx skills add https://github.com/erichowens/some_claude_skills --skill pixel-art-scalerAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 98 |
|---|---|
| repo stars | ★ 178 |
| Last updated | July 14, 2026 |
| Repository | erichowens/some_claude_skills ↗ |
What it does
Upscale pixel art using edge-aware algorithms (EPX, hq2x, xBR) for retina displays.
Files
Pixel Art Scaler
Deterministic algorithms for upscaling pixel art that preserve aesthetics by adding valid sub-pixels through edge detection and pattern matching.
When to Use
✅ Use for:
- Upscaling retro game sprites, icons, and pixel art
- 2x, 3x, 4x scaling with edge-aware interpolation
- Preserving sharp pixel art aesthetic at higher resolutions
- Converting 8x8, 16x16, 32x32, 48x48 pixel art for retina displays
- Comparing deterministic vs AI/ML approaches
❌ NOT for:
- Photographs or realistic images (use AI super-resolution)
- Simple geometric scaling (use nearest-neighbor)
- Vector art (use SVG)
- Text rendering (use font hinting)
- Arbitrary non-integer scaling (algorithms work best at 2x, 3x, 4x)
Core Algorithms
1. EPX/Scale2x (Fastest, Good Quality)
Best for: Quick iteration, 2x/3x scaling, transparent sprites
How it works:
- Examines each pixel and its 4 cardinal neighbors (N, S, E, W)
- Expands 1 pixel → 4 pixels (2x) or 9 pixels (3x) using edge detection
- Only uses colors from original palette (no new colors)
- Handles transparency correctly
When to use:
- Need fast processing (100+ icons)
- Want crisp edges with no anti-aliasing
- Source has clean pixel boundaries
- Transparency preservation is critical
Timeline: Invented by Eric Johnston at LucasArts (~1992), rediscovered by Andrea Mazzoleni (2001)
2. hq2x/hq3x/hq4x (High Quality, Slower)
Best for: Final renders, complex sprites, smooth gradients
How it works:
- Pattern matching on 3x3 neighborhoods (256 possible patterns)
- YUV color space thresholds for edge detection
- Sophisticated interpolation rules per pattern
- Produces smooth, anti-aliased edges
When to use:
- Final production assets
- Source has gradients or dithering
- Want smooth, anti-aliased results
- Processing time is acceptable (~5-10x slower than EPX)
Timeline: Developed by Maxim Stepin for emulators (2003)
3. xBR/Super-xBR (Highest Quality, Slowest)
Best for: Hero assets, promotional materials, detailed sprites
How it works:
- Advanced edge detection with weighted blending
- Multiple passes for smoother results (Super-xBR)
- Preserves fine details while smoothing edges
- Best anti-aliasing of the three algorithms
When to use:
- Maximum quality needed
- Complex sprites with fine details
- Marketing/promotional use
- Time is not a constraint (~20x slower than EPX)
Timeline: xBR by Hyllian (2011), Super-xBR (2015)
Anti-Patterns
Anti-Pattern: Nearest-Neighbor for Display
Novice thinking: "Just use nearest-neighbor 4x, it preserves pixels"
Reality: Nearest-neighbor creates blocky repetition without adding detail. Each pixel becomes NxN identical blocks, which looks crude on high-DPI displays.
What deterministic algorithms do: Add valid sub-pixels through pattern recognition - a diagonal edge gets anti-aliased pixels, straight edges stay crisp.
Timeline:
- Pre-2000s: Nearest-neighbor was only option
- 2001+: EPX/Scale2x enabled smart 2x scaling
- 2003+: hq2x added sophisticated pattern matching
- 2011+: xBR became state-of-the-art
When nearest-neighbor IS correct: Viewing pixel art at exact integer multiples in pixel-perfect contexts (e.g., 1:1 reference images).
Anti-Pattern: Using AI/ML for Pixel Art
Novice thinking: "Real-ESRGAN / Waifu2x will give better results"
Reality: AI models trained on photos/anime add inappropriate detail to pixel art. They invent textures and smooth edges that shouldn't exist, destroying the intentional pixel-level decisions.
LLM mistake: Training data includes "upscaling = use AI models" advice from photo editing contexts.
Correct approach:
| Source Type | Algorithm |
|---|---|
| Pixel art (sprites, icons) | EPX/hq2x/xBR (this skill) |
| Pixel art photos (screenshots) | Hybrid: xBR first, then light AI |
| Photos/realistic art | AI super-resolution |
| Mixed content | Test both, compare results |
Anti-Pattern: Wrong Algorithm for Context
Novice thinking: "Always use the highest quality algorithm"
Reality: Different algorithms serve different purposes:
| Context | Algorithm | Why |
|---|---|---|
| Iteration/prototyping | EPX | 10x faster, good enough |
| Production assets (web) | hq2x | Balance of quality/size |
| Hero images (marketing) | xBR | Maximum quality |
| Transparent sprites | EPX | Best transparency handling |
| Complex gradients | hq4x | Best gradient interpolation |
Validation: Always compare outputs visually - sometimes EPX 2x looks better than hq4x!
Usage
Quick Start
# Install dependencies
cd ~/.claude/skills/pixel-art-scaler/scripts
pip install Pillow numpy
# Scale a single icon with EPX 2x (fastest)
python3 scale_epx.py input.png output.png --scale 2
# Scale with hq2x (high quality)
python3 scale_hqx.py input.png output.png --scale 2
# Scale with xBR (maximum quality)
python3 scale_xbr.py input.png output.png --scale 2
# Batch process directory
python3 batch_scale.py input_dir/ output_dir/ --algorithm epx --scale 2
# Compare all algorithms side-by-side
python3 compare_algorithms.py input.png output_comparison.htmlAlgorithm Selection Guide
Decision tree:
Need to scale pixel art?
├── Transparency important? → EPX
├── Fast iteration needed? → EPX
├── Complex gradients/dithering? → hq2x or hq4x
├── Maximum quality for hero asset? → xBR
└── Not sure? → Run compare_algorithms.pyTypical Workflow
1. Prototype with EPX 2x: Process all assets quickly 2. Review results: Identify which need higher quality 3. Re-process heroes with hq4x or xBR: Apply to key assets only 4. Compare outputs: Use compare_algorithms.py for side-by-side 5. Optimize: Sometimes 2x looks better than 4x (test both)
Scripts Reference
All scripts in scripts/ directory:
| Script | Purpose | Speed | Quality |
|---|---|---|---|
scale_epx.py | EPX/Scale2x implementation | Fast | Good |
scale_hqx.py | hq2x/hq3x/hq4x implementation | Medium | Great |
scale_xbr.py | xBR/Super-xBR implementation | Slow | Best |
batch_scale.py | Process directories | Varies | Varies |
compare_algorithms.py | Generate comparison HTML | N/A | N/A |
Each script includes:
- CLI interface with
--help - Transparency preservation
- Error handling for corrupted inputs
- Progress indicators for batch operations
Technical Details
Color Space Considerations
EPX: Works in RGB, binary edge detection hq2x/hq4x: Uses YUV color space with thresholds (Y=48, Cb=7, Cr=6) xBR: Advanced edge weighting in RGB with luminance consideration
Transparency Handling
All algorithms preserve alpha channel:
- Transparent pixels don't influence edge detection
- Semi-transparent pixels are handled correctly
- Output maintains RGBA format if input has alpha
Performance Benchmarks (M4 Max, 48x48 input)
| Algorithm | Time (1 image) | Batch (100 images) |
|---|---|---|
| EPX 2x | 0.01s | 1s |
| EPX 3x | 0.02s | 2s |
| hq2x | 0.10s | 10s |
| hq4x | 0.30s | 30s |
| xBR 2x | 0.15s | 15s |
| xBR 4x | 0.50s | 50s |
Rule of thumb: EPX is ~10x faster than hq2x, ~20x faster than xBR
Output Validation
After scaling, verify results:
# Check output dimensions
identify output.png # Should be exactly 2x, 3x, or 4x input
# Visual inspection
open output.png # Look for artifacts, incorrect edges
# Compare algorithms
python3 compare_algorithms.py input.png comparison.html
open comparison.html # Side-by-side comparisonCommon issues:
- Jagged diagonals → Try hq2x or xBR instead of EPX
- Blurry edges → Check if input was already scaled (apply to original)
- Wrong colors → Verify input is RGB/RGBA (not indexed/paletted PNG)
References
Deep Dives
/references/algorithm-comparison.md- Visual examples and trade-offs/references/epx-algorithm.md- EPX/Scale2x implementation details/references/hqx-patterns.md- hq2x pattern matching table explanation/references/xbr-edge-detection.md- xBR edge weighting formulas
Research Papers & Sources
- Pixel-art scaling algorithms - Wikipedia
- Scale2x & EPX official site
- hqx: An Image Scaling Algorithm for Pixel Art
- py-super-xbr GitHub
Example Assets
/assets/test-sprites/- Sample sprites for testing algorithms/assets/expected-outputs/- Reference outputs for validation
Changelog
- 2026-02-05: Initial skill creation with EPX, hq2x, xBR implementations
#!/usr/bin/env python3
"""
Batch Pixel Art Scaling
Process entire directories of pixel art with EPX algorithm.
Usage:
python3 batch_scale.py input_dir/ output_dir/ --scale 2
python3 batch_scale.py input_dir/ output_dir/ --scale 2 --double
Requirements:
pip install Pillow numpy
"""
import argparse
import sys
from pathlib import Path
import time
from scale_epx import scale2x_epx
import numpy as np
from PIL import Image
def process_directory(input_dir: Path, output_dir: Path, scale: int = 2, double: bool = False):
"""
Process all PNG files in input directory
Args:
input_dir: Input directory
output_dir: Output directory
scale: Scale factor (2 or 3)
double: Apply algorithm twice for 4x (only with scale=2)
"""
# Find all PNG files
png_files = list(input_dir.glob("*.png"))
if not png_files:
print(f"No PNG files found in {input_dir}")
return
print(f"Found {len(png_files)} PNG files")
print(f"Scale: {scale}x" + (" (doubled to 4x)" if double else ""))
print(f"Output: {output_dir}\n")
# Create output directory
output_dir.mkdir(parents=True, exist_ok=True)
# Process each file
success_count = 0
total_time = 0
for i, input_path in enumerate(png_files, 1):
print(f"[{i}/{len(png_files)}] {input_path.name}...", end=" ")
try:
start = time.time()
# Load image
img = Image.open(input_path)
if img.mode not in ('RGBA', 'RGB'):
img = img.convert('RGBA')
pixels = np.array(img)
orig_size = f"{pixels.shape[1]}x{pixels.shape[0]}"
# Apply EPX
if scale == 2:
scaled = scale2x_epx(pixels)
if double:
scaled = scale2x_epx(scaled)
method = "EPX 4x (2x doubled)"
else:
method = "EPX 2x"
elif scale == 3:
from scale_epx import scale3x_epx
scaled = scale3x_epx(pixels)
method = "EPX 3x"
if double:
print("Warning: Double mode only works with scale=2, ignoring")
else:
raise ValueError(f"Unsupported scale: {scale}")
# Save
output_path = output_dir / input_path.name
output_img = Image.fromarray(scaled)
output_img.save(
output_path,
'PNG',
optimize=True,
compress_level=9
)
elapsed = time.time() - start
total_time += elapsed
new_size = f"{scaled.shape[1]}x{scaled.shape[0]}"
print(f"✓ {orig_size} → {new_size} ({elapsed*1000:.0f}ms)")
success_count += 1
except Exception as e:
print(f"✗ Failed: {e}")
# Summary
print(f"\n{'='*60}")
print(f"Completed: {success_count}/{len(png_files)} files")
print(f"Total time: {total_time:.1f}s")
print(f"Average: {(total_time/len(png_files))*1000:.0f}ms per file")
print(f"Output directory: {output_dir}")
def main():
parser = argparse.ArgumentParser(
description="Batch pixel art upscaling with EPX algorithm",
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog="""
Examples:
# Process all icons at 2x
python3 batch_scale.py skill-art/ skill-art-2x/ --scale 2
# Process at 4x (2x applied twice)
python3 batch_scale.py skill-art/ skill-art-4x/ --scale 2 --double
# Process at 3x
python3 batch_scale.py skill-art/ skill-art-3x/ --scale 3
Performance:
EPX is very fast - expect ~10-20ms per 48x48 icon on modern hardware.
100 icons typically processes in ~1-2 seconds.
"""
)
parser.add_argument(
'input_dir',
type=Path,
help='Input directory containing PNG files'
)
parser.add_argument(
'output_dir',
type=Path,
help='Output directory for scaled files'
)
parser.add_argument(
'--scale',
type=int,
choices=[2, 3],
default=2,
help='Scale factor (default: 2)'
)
parser.add_argument(
'--double',
action='store_true',
help='Apply algorithm twice for 4x scaling (only with --scale 2)'
)
args = parser.parse_args()
# Validate
if not args.input_dir.exists():
print(f"Error: Input directory not found: {args.input_dir}", file=sys.stderr)
sys.exit(1)
if not args.input_dir.is_dir():
print(f"Error: Input path is not a directory: {args.input_dir}", file=sys.stderr)
sys.exit(1)
# Process
try:
process_directory(args.input_dir, args.output_dir, args.scale, args.double)
except Exception as e:
print(f"Error: {e}", file=sys.stderr)
import traceback
traceback.print_exc()
sys.exit(1)
if __name__ == '__main__':
main()
#!/usr/bin/env python3
"""
Compare Pixel Art Scaling Algorithms
Generates side-by-side HTML comparison of all available algorithms:
- Original (1x)
- Nearest-neighbor (for reference)
- EPX 2x
- EPX 4x (EPX 2x applied twice)
Usage:
python3 compare_algorithms.py input.png output.html
python3 compare_algorithms.py input.png output.html --scale 2
Requirements:
pip install Pillow numpy
"""
import argparse
import base64
import sys
from pathlib import Path
from io import BytesIO
import numpy as np
from PIL import Image
# Import our EPX implementation
from scale_epx import scale2x_epx
def nearest_neighbor(pixels: np.ndarray, scale: int) -> np.ndarray:
"""Simple nearest-neighbor scaling for comparison"""
height, width = pixels.shape[:2]
output = np.zeros((height * scale, width * scale, pixels.shape[2]), dtype=pixels.dtype)
for y in range(height * scale):
for x in range(width * scale):
src_y = y // scale
src_x = x // scale
output[y, x] = pixels[src_y, src_x]
return output
def img_to_base64(img: Image.Image) -> str:
"""Convert PIL Image to base64 data URL"""
buffered = BytesIO()
img.save(buffered, format="PNG")
img_str = base64.b64encode(buffered.getvalue()).decode()
return f"data:image/png;base64,{img_str}"
def generate_comparison_html(input_path: Path, output_path: Path, scale: int = 2):
"""
Generate HTML comparison of scaling algorithms
Args:
input_path: Input PNG file
output_path: Output HTML file
scale: Target scale factor (2 or 4)
"""
# Load image
img = Image.open(input_path)
if img.mode not in ('RGBA', 'RGB'):
img = img.convert('RGBA')
pixels = np.array(img)
orig_width, orig_height = pixels.shape[1], pixels.shape[0]
print(f"Processing {input_path}")
print(f"Original: {orig_width}x{orig_height}")
# Generate variations
results = {}
# Original
results['original'] = {
'name': 'Original (1x)',
'image': img,
'description': f'{orig_width}×{orig_height} source image',
'time': 'N/A'
}
# Nearest-neighbor (baseline)
import time
start = time.time()
nn_pixels = nearest_neighbor(pixels, scale)
nn_time = time.time() - start
results['nearest'] = {
'name': f'Nearest-Neighbor {scale}x',
'image': Image.fromarray(nn_pixels, mode=img.mode),
'description': 'Simple NxN block repetition (blocky)',
'time': f'{nn_time*1000:.1f}ms'
}
# EPX 2x
start = time.time()
epx2_pixels = scale2x_epx(pixels)
epx2_time = time.time() - start
results['epx2'] = {
'name': 'EPX 2x',
'image': Image.fromarray(epx2_pixels, mode=img.mode),
'description': 'Edge-aware 2x scaling (crisp edges)',
'time': f'{epx2_time*1000:.1f}ms'
}
# EPX 4x (double application)
if scale >= 4:
start = time.time()
epx4_pixels = scale2x_epx(epx2_pixels)
epx4_time = time.time() - start
results['epx4'] = {
'name': 'EPX 4x',
'image': Image.fromarray(epx4_pixels, mode=img.mode),
'description': 'EPX applied twice (2x → 4x)',
'time': f'{(epx2_time + epx4_time)*1000:.1f}ms'
}
# Generate HTML
html = f"""<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Pixel Art Scaling Comparison - {input_path.name}</title>
<style>
* {{
margin: 0;
padding: 0;
box-sizing: border-box;
}}
body {{
font-family: 'Monaco', 'Consolas', monospace;
background: #1e1e1e;
color: #d4d4d4;
padding: 20px;
}}
.container {{
max-width: 1400px;
margin: 0 auto;
}}
h1 {{
font-size: 24px;
margin-bottom: 10px;
color: #4ec9b0;
border-bottom: 2px solid #4ec9b0;
padding-bottom: 10px;
}}
.meta {{
margin-bottom: 30px;
font-size: 14px;
color: #808080;
}}
.comparison-grid {{
display: grid;
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
gap: 20px;
margin-bottom: 40px;
}}
.result-card {{
background: #252526;
border: 1px solid #3e3e42;
border-radius: 4px;
padding: 15px;
transition: transform 0.2s;
}}
.result-card:hover {{
transform: translateY(-2px);
border-color: #4ec9b0;
}}
.result-header {{
margin-bottom: 10px;
}}
.result-name {{
font-size: 16px;
font-weight: bold;
color: #4ec9b0;
margin-bottom: 5px;
}}
.result-time {{
font-size: 12px;
color: #ce9178;
}}
.result-image {{
background: #2d2d30;
border: 1px solid #3e3e42;
padding: 20px;
margin-bottom: 10px;
text-align: center;
image-rendering: pixelated;
image-rendering: -moz-crisp-edges;
image-rendering: crisp-edges;
}}
.result-image img {{
max-width: 100%;
height: auto;
image-rendering: pixelated;
image-rendering: -moz-crisp-edges;
image-rendering: crisp-edges;
}}
.result-description {{
font-size: 13px;
color: #9cdcfe;
line-height: 1.4;
}}
.legend {{
background: #252526;
border: 1px solid #4ec9b0;
border-radius: 4px;
padding: 20px;
margin-top: 30px;
}}
.legend h2 {{
font-size: 18px;
color: #4ec9b0;
margin-bottom: 15px;
}}
.legend-content {{
display: grid;
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
gap: 15px;
}}
.legend-item {{
font-size: 13px;
line-height: 1.6;
}}
.legend-label {{
color: #ce9178;
font-weight: bold;
}}
.footer {{
margin-top: 40px;
padding-top: 20px;
border-top: 1px solid #3e3e42;
font-size: 12px;
color: #808080;
text-align: center;
}}
</style>
</head>
<body>
<div class="container">
<h1>Pixel Art Scaling Comparison</h1>
<div class="meta">
<div>Source: <strong>{input_path.name}</strong></div>
<div>Original size: <strong>{orig_width}×{orig_height}</strong></div>
<div>Target scale: <strong>{scale}x</strong></div>
</div>
<div class="comparison-grid">
"""
# Add result cards
for key, result in results.items():
img_data = img_to_base64(result['image'])
width, height = result['image'].size
html += f"""
<div class="result-card">
<div class="result-header">
<div class="result-name">{result['name']}</div>
<div class="result-time">Time: {result['time']}</div>
</div>
<div class="result-image">
<img src="{img_data}" alt="{result['name']}" width="{width}" height="{height}">
</div>
<div class="result-description">
{result['description']}<br>
Size: {width}×{height}
</div>
</div>
"""
html += """
</div>
<div class="legend">
<h2>Algorithm Comparison</h2>
<div class="legend-content">
<div class="legend-item">
<span class="legend-label">Nearest-Neighbor:</span><br>
Simple NxN block repetition. Fastest but looks blocky. Each original pixel becomes a solid block.
</div>
<div class="legend-item">
<span class="legend-label">EPX/Scale2x:</span><br>
Edge-aware scaling using cardinal neighbors. Preserves sharp edges while smoothing diagonals. 10x faster than hq2x.
</div>
<div class="legend-item">
<span class="legend-label">EPX 4x (Double):</span><br>
EPX algorithm applied twice (2x → 4x). Compounds edge-aware benefits. Good balance of speed and quality.
</div>
<div class="legend-item">
<span class="legend-label">When to use each:</span><br>
• Nearest: Never for display (reference only)<br>
• EPX 2x: Fast iteration, transparent sprites<br>
• EPX 4x: Production retina assets
</div>
</div>
</div>
<div class="footer">
Generated by pixel-art-scaler skill | All images use pixelated rendering for accurate preview
</div>
</div>
</body>
</html>
"""
# Write HTML
output_path.write_text(html)
print(f"\n✓ Comparison saved to: {output_path}")
print(f" Open in browser: file://{output_path.absolute()}")
def main():
parser = argparse.ArgumentParser(
description="Compare pixel art scaling algorithms side-by-side",
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog="""
Examples:
python3 compare_algorithms.py sprite.png comparison.html
python3 compare_algorithms.py icon.png comparison.html --scale 4
Output:
Interactive HTML with side-by-side comparisons of all algorithms.
Uses image-rendering: pixelated for accurate preview.
"""
)
parser.add_argument(
'input',
type=Path,
help='Input PNG file'
)
parser.add_argument(
'output',
type=Path,
help='Output HTML file'
)
parser.add_argument(
'--scale',
type=int,
choices=[2, 4],
default=2,
help='Target scale factor (default: 2)'
)
args = parser.parse_args()
# Validate
if not args.input.exists():
print(f"Error: Input file not found: {args.input}", file=sys.stderr)
sys.exit(1)
# Create output directory
args.output.parent.mkdir(parents=True, exist_ok=True)
# Generate comparison
try:
generate_comparison_html(args.input, args.output, args.scale)
except Exception as e:
print(f"Error: {e}", file=sys.stderr)
import traceback
traceback.print_exc()
sys.exit(1)
if __name__ == '__main__':
main()
#!/usr/bin/env python3
"""
EPX/Scale2x Pixel Art Upscaling
Eric's Pixel Expansion algorithm - fast, deterministic pixel art scaling
that preserves sharp edges and only uses original palette colors.
Usage:
python3 scale_epx.py input.png output.png --scale 2
python3 scale_epx.py input.png output.png --scale 3
Requirements:
pip install Pillow numpy
"""
import argparse
import sys
from pathlib import Path
import numpy as np
from PIL import Image
def scale2x_epx(pixels: np.ndarray) -> np.ndarray:
"""
EPX/Scale2x algorithm - 2x upscaling
For each pixel P with neighbors:
A
C P B
D
Output 2x2 block:
P1 P2
P3 P4
Rules:
P1 = (C == A and C != D and A != B) ? A : P
P2 = (A == B and A != C and B != D) ? B : P
P3 = (D == C and D != B and C != A) ? C : P
P4 = (B == D and B != A and D != C) ? D : P
"""
height, width = pixels.shape[:2]
has_alpha = pixels.shape[2] == 4
# Create output array (2x size)
output = np.zeros((height * 2, width * 2, pixels.shape[2]), dtype=pixels.dtype)
for y in range(height):
for x in range(width):
# Get center pixel
P = pixels[y, x]
# Get neighbors (with boundary handling)
A = pixels[y - 1, x] if y > 0 else P
B = pixels[y, x + 1] if x < width - 1 else P
C = pixels[y, x - 1] if x > 0 else P
D = pixels[y + 1, x] if y < height - 1 else P
# Output 2x2 block positions
out_y = y * 2
out_x = x * 2
# Apply EPX rules
# P1 (top-left)
if np.array_equal(C, A) and not np.array_equal(C, D) and not np.array_equal(A, B):
output[out_y, out_x] = A
else:
output[out_y, out_x] = P
# P2 (top-right)
if np.array_equal(A, B) and not np.array_equal(A, C) and not np.array_equal(B, D):
output[out_y, out_x + 1] = B
else:
output[out_y, out_x + 1] = P
# P3 (bottom-left)
if np.array_equal(D, C) and not np.array_equal(D, B) and not np.array_equal(C, A):
output[out_y + 1, out_x] = C
else:
output[out_y + 1, out_x] = P
# P4 (bottom-right)
if np.array_equal(B, D) and not np.array_equal(B, A) and not np.array_equal(D, C):
output[out_y + 1, out_x + 1] = D
else:
output[out_y + 1, out_x + 1] = P
return output
def scale3x_epx(pixels: np.ndarray) -> np.ndarray:
"""
Scale3x algorithm - 3x upscaling
Similar logic to Scale2x but outputs 3x3 block per pixel.
Uses the same neighbor pattern but with 9 output positions.
"""
height, width = pixels.shape[:2]
output = np.zeros((height * 3, width * 3, pixels.shape[2]), dtype=pixels.dtype)
for y in range(height):
for x in range(width):
P = pixels[y, x]
# Get neighbors
A = pixels[y - 1, x] if y > 0 else P
B = pixels[y, x + 1] if x < width - 1 else P
C = pixels[y, x - 1] if x > 0 else P
D = pixels[y + 1, x] if y < height - 1 else P
# Diagonal neighbors for Scale3x
E = pixels[y - 1, x - 1] if y > 0 and x > 0 else P
F = pixels[y - 1, x + 1] if y > 0 and x < width - 1 else P
G = pixels[y + 1, x - 1] if y < height - 1 and x > 0 else P
H = pixels[y + 1, x + 1] if y < height - 1 and x < width - 1 else P
out_y = y * 3
out_x = x * 3
# Scale3x rules (simplified version)
# Row 0
output[out_y, out_x] = A if np.array_equal(C, A) else P
output[out_y, out_x + 1] = A if np.array_equal(A, B) or np.array_equal(A, C) else P
output[out_y, out_x + 2] = B if np.array_equal(A, B) else P
# Row 1 (middle)
output[out_y + 1, out_x] = C if np.array_equal(C, A) or np.array_equal(C, D) else P
output[out_y + 1, out_x + 1] = P # Center is always P
output[out_y + 1, out_x + 2] = B if np.array_equal(B, A) or np.array_equal(B, D) else P
# Row 2
output[out_y + 2, out_x] = C if np.array_equal(C, D) else P
output[out_y + 2, out_x + 1] = D if np.array_equal(D, B) or np.array_equal(D, C) else P
output[out_y + 2, out_x + 2] = D if np.array_equal(B, D) else P
return output
def scale_epx(input_path: Path, output_path: Path, scale: int = 2):
"""
Scale pixel art using EPX/Scale2x/Scale3x algorithm
Args:
input_path: Input PNG file
output_path: Output PNG file
scale: Scale factor (2 or 3)
"""
# Load image
img = Image.open(input_path)
# Convert to RGBA if needed (preserve transparency)
if img.mode not in ('RGBA', 'RGB'):
img = img.convert('RGBA')
# Convert to numpy array
pixels = np.array(img)
print(f"Input: {pixels.shape[1]}x{pixels.shape[0]} ({img.mode})")
# Apply algorithm
if scale == 2:
scaled = scale2x_epx(pixels)
elif scale == 3:
scaled = scale3x_epx(pixels)
else:
raise ValueError(f"Unsupported scale: {scale}. Use 2 or 3.")
# Convert back to image
output_img = Image.fromarray(scaled, mode=img.mode)
# Save with maximum quality
output_img.save(
output_path,
'PNG',
optimize=True,
compress_level=9
)
print(f"Output: {scaled.shape[1]}x{scaled.shape[0]} → {output_path}")
print(f"Algorithm: EPX/Scale{scale}x (deterministic edge-aware)")
def main():
parser = argparse.ArgumentParser(
description="EPX/Scale2x/Scale3x pixel art upscaling",
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog="""
Examples:
python3 scale_epx.py sprite.png sprite_2x.png --scale 2
python3 scale_epx.py icon.png icon_3x.png --scale 3
Algorithm:
EPX (Eric's Pixel Expansion) examines each pixel's 4 cardinal neighbors
and intelligently expands to 2x2 or 3x3 blocks based on edge detection.
Pros: Fast, preserves sharp edges, handles transparency
Cons: Less sophisticated than hq2x/xBR (no gradient smoothing)
"""
)
parser.add_argument(
'input',
type=Path,
help='Input PNG file'
)
parser.add_argument(
'output',
type=Path,
help='Output PNG file'
)
parser.add_argument(
'--scale',
type=int,
choices=[2, 3],
default=2,
help='Scale factor (default: 2)'
)
args = parser.parse_args()
# Validate input
if not args.input.exists():
print(f"Error: Input file not found: {args.input}", file=sys.stderr)
sys.exit(1)
# Create output directory if needed
args.output.parent.mkdir(parents=True, exist_ok=True)
# Scale
try:
scale_epx(args.input, args.output, args.scale)
print("✓ Success")
except Exception as e:
print(f"Error: {e}", file=sys.stderr)
sys.exit(1)
if __name__ == '__main__':
main()