Python – Noise generator

Written by:

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×256 up to 2048x2048
  • Live preview using matplotlib embedded 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 .png or .jpg

Stack and Libraries

  • PyQt5: GUI framework
  • Matplotlib: For inline preview rendering
  • NumPy: Efficient array operations
  • Noise libraries:

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