Over a weekend and a couple of week nights, I decided to build a simple tool to experiment with noise generation and get more familiar with PyQt.
I’ve been working on a project that requires procedural noise masks like Perlin, Simplex, Worley, and Voronoi noise. At the same time, I’d been meaning to explore Qt for Python GUI development. This tool was a way to bring both together.
What the Tool Does
This script generates various noise patterns with real-time controls with a PyQt5 interface.
Key Features:
- Generate 4 types of noise:
- Perlin
- Simplex
- Worley
- Voronoi – added out of curiosity
- Adjustable resolution: from
256×256up to2048x2048 - Live preview using
matplotlibembedded in the GUI - Noise configuration sliders:
- Scale (zoom level)
- Octaves, persistence, lacunarity (for layered noise)
- Seed (manual or random 6-digit)
- Filename preview and export to
.pngor.jpg
Stack and Libraries
- PyQt5: GUI framework
- Matplotlib: For inline preview rendering
- NumPy: Efficient array operations
- Noise libraries:
noisefor Perlinopensimplexfor Simplexpythonworleyfor Worleyscipy.spatialfor Voronoi
UI Layout
The UI is built with a vertical layout and grouped control sections:
- Noise type dropdown — select Perlin, Simplex, Worley, or Voronoi
- Resolution selector — choose from common resolutions
- Sliders — adjust scale, octaves, persistence, lacunarity
- Seed input — manually enter a seed or generate one randomly
- Live canvas — view the generated noise in real time
- Export controls — preview filename and export as image
Noise Logic Overview
Perlin
Uses the pnoise2 function with the selected scale, octaves, persistence, and lacunarity. The repeatx/repeaty params were removed to avoid tiling artifacts.
pnoise2(x / scale, y / scale, octaves=..., persistence=..., lacunarity=..., base=seed)

Simplex
Implements fractal noise by summing multiple octaves with adjusted frequency and amplitude.
for _ in range(octaves):
value += opensimplex.noise2(x * freq, y * freq) * amp
freq *= lacunarity
amp *= persistence

Worley
Uses the pythonworley module with adjustable density tied to the scale slider. The resulting map is tiled to fit the chosen resolution.

Voronoi
Creates a tessellation from seed points using scipy.spatial.Voronoi and renders it via matplotlib polygons and lines.

Saving Images
When saving, the tool generates a default filename like simplex_1024x1024.png, which you can override with a custom name.
self.figure.savefig(file_path, dpi=100)
It preserves the selected resolution exactly with no borders or padding.
Example Use Case
Let’s say I want a 1024×1024 Simplex noise mask:
- Select Perlin
- Set resolution to
1024x1024 - Set scale to 128, octaves to 8, persistence to 0.1, lacunarity to 6.0
- Click Save — now you have
perlin_1024x1024.png

Future Improvements
- Add seamless tiling toggle
- Additional noise types like OpenSimplex2 or Gabor noise
The Code
import sys
import random
import numpy as np
import matplotlib.pyplot as plt
from matplotlib.backends.backend_qt5agg import FigureCanvasQTAgg as FigureCanvas
from PyQt5.QtWidgets import (QApplication, QWidget, QVBoxLayout, QHBoxLayout,QPushButton, QLabel, QLineEdit, QFileDialog, QComboBox, QSlider)
from PyQt5.QtCore import Qt
from noise import pnoise2
import opensimplex
from pythonworley import noisecoords, worley
from scipy import spatial
# --- Constants ---
resolutions = {
"256x256": (256, 256),
"512x512": (512, 512),
"1024x1024": (1024, 1024),
"2048x2048": (2048, 2048)
}
scale = 128
shape = (4, 4)
dens = scale
# --- Main GUI ---
class NoiseApp(QWidget):
def __init__(self):
super().__init__()
self.setWindowTitle("2D Noise Generator")
self.layout = QVBoxLayout()
self.globalseed = random.randint(100000, 999999)
self.width, self.height = 512, 512
self.noise_img = np.zeros((self.height, self.width))
self.init_ui()
self.update_noise()
def init_ui(self):
self.init_scale_controls()
self.init_seed_controls()
self.init_resolution_controls()
self.init_noise_controls()
self.init_filename_controls()
self.init_save_controls()
self.init_canvas()
self.setLayout(self.layout)
def init_scale_controls(self):
scale_layout = QHBoxLayout()
self.scale_slider = QSlider(Qt.Horizontal)
self.scale_slider.setMinimum(1)
self.scale_slider.setMaximum(1000)
self.scale_slider.setValue(scale)
self.scale_slider.valueChanged.connect(self.on_scale_changed)
self.scale_value_label = QLabel(str(scale))
self.scale_label = QLabel(f"Scale: {scale}")
self.octaves_slider = QSlider(Qt.Horizontal)
self.octaves_slider.setMinimum(1)
self.octaves_slider.setMaximum(16)
self.octaves_slider.setValue(8)
self.octaves_slider.valueChanged.connect(self.update_noise)
self.octaves_label = QLabel("Octaves: 8")
self.persistence_slider = QSlider(Qt.Horizontal)
self.persistence_slider.setMinimum(1)
self.persistence_slider.setMaximum(100)
self.persistence_slider.setValue(10)
self.persistence_slider.valueChanged.connect(self.update_noise)
self.persistence_label = QLabel("Persistence: 0.1")
self.lacunarity_slider = QSlider(Qt.Horizontal)
self.lacunarity_slider.setMinimum(10)
self.lacunarity_slider.setMaximum(300)
self.lacunarity_slider.setValue(60)
self.lacunarity_slider.valueChanged.connect(self.update_noise)
self.lacunarity_label = QLabel("Lacunarity: 6.0")
scale_layout.addWidget(self.scale_label)
scale_layout.addWidget(self.scale_slider)
scale_layout.addWidget(self.scale_value_label)
self.layout.addLayout(scale_layout)
self.layout.addWidget(self.octaves_label)
self.layout.addWidget(self.octaves_slider)
self.layout.addWidget(self.persistence_label)
self.layout.addWidget(self.persistence_slider)
self.layout.addWidget(self.lacunarity_label)
self.layout.addWidget(self.lacunarity_slider)
def init_seed_controls(self):
seed_layout = QHBoxLayout()
self.seed_input = QLineEdit()
self.seed_input.setPlaceholderText("Enter 6-digit seed")
seed_btn = QPushButton("Generate Seed")
seed_btn.clicked.connect(self.generate_seed)
self.seed_label = QLabel(f"Global Seed: {self.globalseed}")
seed_layout.addWidget(self.seed_input)
seed_layout.addWidget(seed_btn)
self.layout.addLayout(seed_layout)
self.layout.addWidget(self.seed_label)
def init_resolution_controls(self):
self.res_combo = QComboBox()
self.res_combo.addItems(resolutions.keys())
self.res_combo.setCurrentText("512x512")
self.res_combo.currentTextChanged.connect(self.change_resolution)
self.layout.addWidget(QLabel("Resolution:"))
self.layout.addWidget(self.res_combo)
def init_noise_controls(self):
self.noise_combo = QComboBox()
self.noise_combo.addItems(["Perlin", "Simplex", "Worley", "Voronoi"])
self.noise_combo.currentTextChanged.connect(self.update_noise)
self.layout.addWidget(QLabel("Noise Type:"))
self.layout.addWidget(self.noise_combo)
self.noise_status = QLabel()
self.layout.addWidget(self.noise_status)
def init_filename_controls(self):
self.filename_input = QLineEdit()
self.filename_input.setPlaceholderText("Optional custom filename (without extension)")
self.layout.addWidget(self.filename_input)
self.filename_preview = QLabel()
self.layout.addWidget(self.filename_preview)
def init_save_controls(self):
save_btn = QPushButton("Save Image")
save_btn.clicked.connect(self.save_image)
self.layout.addWidget(save_btn)
def init_canvas(self):
self.figure, self.ax = plt.subplots()
self.canvas = FigureCanvas(self.figure)
self.layout.addWidget(self.canvas)
# --- MainFunctions ---
def generate_seed(self):
text = self.seed_input.text()
self.globalseed = int(text) if text.isdigit() and len(text) == 6 else random.randint(100000, 999999)
self.seed_label.setText(f"Global Seed: {self.globalseed}")
self.update_noise()
def change_resolution(self, text):
self.width, self.height = resolutions[text]
self.noise_img = np.zeros((self.height, self.width))
self.update_noise()
def on_scale_changed(self):
val = self.scale_slider.value()
self.scale_label.setText(f"Scale:")
self.scale_value_label.setText(str(val))
self.update_noise()
def update_plot(self, data, title, cmap='gray'):
self.ax.clear()
self.ax.imshow(data, cmap=cmap)
self.ax.axis('off')
self.canvas.draw()
def save_image(self):
noise_name = self.noise_combo.currentText().lower()
res_label = self.res_combo.currentText().replace(' ', '').lower()
custom_name = self.filename_input.text().strip()
default_ext = '.png'
default_name = f"{custom_name}{default_ext}" if custom_name else f"{noise_name}_{res_label}{default_ext}"
file_path, _ = QFileDialog.getSaveFileName(
self, "Save Image", default_name, "PNG Files (*.png);;JPEG Files (*.jpg);;All Files (*)"
)
if file_path:
dpi = 100
figsize = (self.width/dpi, self.height/dpi)
self.figure.set_size_inches(*figsize)
self.ax.set_position([0, 0, 1, 1])
self.figure.savefig(file_path, dpi=dpi)
def perlin(self):
self.noise_img = np.zeros((self.height, self.width))
seed = self.globalseed % 1024
for y in range(self.height):
for x in range(self.width):
self.noise_img[y][x] = pnoise2(x / self.scale, y / self.scale,
octaves=self.octaves, persistence=self.persistence, lacunarity=self.lacunarity,
base=seed)
self.noise_img = (self.noise_img - self.noise_img.min()) / (self.noise_img.max() - self.noise_img.min())
self.update_plot(self.noise_img, "")
def simplex(self):
self.noise_img = np.zeros((self.height, self.width))
for y in range(self.height):
for x in range(self.width):
value = 0
freq = 1.0
amp = 1.0
max_amp = 0
for _ in range(self.octaves):
value += opensimplex.noise2((x / self.scale) * freq, (y / self.scale) * freq) * amp
max_amp += amp
amp *= self.persistence
freq *= self.lacunarity
self.noise_img[y][x] = value / max_amp
self.noise_img = (self.noise_img - self.noise_img.min()) / (self.noise_img.max() - self.noise_img.min())
self.update_plot(self.noise_img, "")
def worley_noise(self):
d = int(self.scale)
w, _ = worley(shape, dens=d, seed=self.globalseed)
w = w[0].T
w = np.tile(w, (self.height // w.shape[0] + 1, self.width // w.shape[1] + 1))
w = w[:self.height, :self.width]
w = (w - w.min()) / (w.max() - w.min())
self.update_plot(w, "")
def voronoi(self):
noise = noisecoords(*shape, boundary=True, seed=self.globalseed)
coords = noise.reshape(2, -1).T
vor = spatial.Voronoi(coords)
vert = vor.vertices
edge = vor.ridge_vertices
face = vor.regions
self.ax.clear()
for f in face:
if len(f) and min(f) > 0:
v = vert[f]
self.ax.fill(v[:, 0], v[:, 1], color=(0.5, 0.5, 0.5))
for e in edge:
if min(e) > 0:
v = vert[e]
self.ax.plot(v[:, 0], v[:, 1], color="black", lw=4)
self.ax.set_xlim(0, shape[0])
self.ax.set_ylim(0, shape[1])
self.ax.axis('off')
self.canvas.draw()
def update_noise(self):
octaves = self.octaves_slider.value()
persistence = self.persistence_slider.value() / 100.0
lacunarity = self.lacunarity_slider.value() / 10.0
self.octaves_label.setText(f"Octaves: {octaves}")
self.persistence_label.setText(f"Persistence: {persistence:.2f}")
self.lacunarity_label.setText(f"Lacunarity: {lacunarity:.1f}")
self.octaves = octaves
self.persistence = persistence
self.lacunarity = lacunarity
self.scale = self.scale_slider.value()
noise_name = self.noise_combo.currentText()
res_label = self.res_combo.currentText()
self.filename_preview.setText(f"Filename preview: {noise_name.lower()}_{res_label.replace(' ', '').lower()}.png")
self.noise_status.setText(f"Current: {noise_name} - {self.width}x{self.height}")
if noise_name == "Perlin":
self.perlin()
elif noise_name == "Simplex":
self.simplex()
elif noise_name == "Worley":
self.worley_noise()
elif noise_name == "Voronoi":
self.voronoi()
# --- Run App ---
if __name__ == '__main__':
app = QApplication(sys.argv)
win = NoiseApp()
win.show()
sys.exit(app.exec_())



Leave a comment