1+ #! /usr/bin/env bash
2+ # License: GPLv3
3+ # Credits: Felipe Facundes
4+
5+ # =============================================================================
6+ # fog-create — Procedural fog/smoke generator with transparent background
7+ # Usage:
8+ # fog-create <WxH> [hex_color]
9+ # fog-create 1920x1080
10+ # fog-create 1920x1080 ffffff
11+ # fog-create 1920x1080 "#ffffff"
12+ # =============================================================================
13+
14+ set -euo pipefail
15+
16+ # =============================================================================
17+ # Isolated Python environment (venv) — installed once in ~/.python/
18+ # =============================================================================
19+ py_venv=" ${HOME} /.python/fog-create-venv"
20+ activate=" ${py_venv} /bin/activate"
21+ py_bin=" ${py_venv} /bin/python"
22+
23+ if [[ ! -d " ${py_venv} " ]]; then
24+ echo " Setting up isolated Python environment (first run)..."
25+
26+ # Ensures parent directory exists
27+ mkdir -p " ${HOME} /.python"
28+
29+ # Creates venv with available Python interpreter
30+ if command -v python3 & > /dev/null; then
31+ python3 -m venv " ${py_venv} "
32+ elif command -v python & > /dev/null; then
33+ python -m venv " ${py_venv} "
34+ else
35+ echo " Error: no Python interpreter found (python3 or python)." >&2
36+ exit 1
37+ fi
38+
39+ # Activates and installs dependencies
40+ # shellcheck source=/dev/null
41+ source " ${activate} "
42+ export VIRTUAL_ENV=" ${py_venv} "
43+
44+ echo " Installing dependencies (numpy, pillow, scipy)..."
45+ pip install --quiet --upgrade pip
46+ pip install --quiet numpy pillow scipy
47+
48+ echo " Environment configured at: ${py_venv} "
49+ echo " "
50+ else
51+ # Venv already exists — just activate
52+ # shellcheck source=/dev/null
53+ source " ${activate} "
54+ export VIRTUAL_ENV=" ${py_venv} "
55+ fi
56+
57+ # ---------- help -----------------------------------------------------------
58+ if [[ " ${1:- } " == " -h" || " ${1:- } " == " --help" ]]; then
59+ cat << 'HELP '
60+ fog-create — generates procedural fog (PNG with transparent background)
61+
62+ Usage:
63+ fog-create <WxH> [hex_color]
64+
65+ Examples:
66+ fog-create 1920x1080
67+ fog-create 800x600 ccddff
68+ fog-create 512x512 "#ff8800"
69+
70+ Arguments:
71+ WxH Image dimensions (e.g., 1920x1080)
72+ hex_color Fog color in hex, with or without "#" (default: ffffff)
73+
74+ Output:
75+ fog_<W>x<H>_<color>_<timestamp>.png in current directory
76+ HELP
77+ exit 0
78+ fi
79+
80+ # ---------- arguments ------------------------------------------------------
81+ if [[ $# -lt 1 ]]; then
82+ echo " Usage: fog-create <WxH> [hex_color]" >&2
83+ echo " fog-create --help for more details" >&2
84+ exit 1
85+ fi
86+
87+ DIMENSIONS=" ${1} "
88+ COLOR_RAW=" ${2:- ffffff} "
89+
90+ # Validates dimension
91+ if ! [[ " $DIMENSIONS " =~ ^[0-9]+[xX][0-9]+$ ]]; then
92+ echo " Error: invalid dimension '$DIMENSIONS '. Use WxH format, e.g., 1920x1080" >&2
93+ exit 1
94+ fi
95+
96+ WIDTH=" ${DIMENSIONS%% [xX]* } "
97+ HEIGHT=" ${DIMENSIONS##* [xX]} "
98+
99+ # Normalizes color (removes # and converts to lowercase)
100+ COLOR_HEX=" ${COLOR_RAW// \# / } "
101+ COLOR_HEX=" ${COLOR_HEX,,} "
102+
103+ # Validates hex
104+ if ! [[ " $COLOR_HEX " =~ ^[0-9a-f]{3}([0-9a-f]{3})? $ ]]; then
105+ echo " Error: invalid hex color '$COLOR_RAW '. Use 3 or 6 hex digits, e.g., ffffff or ccddff" >&2
106+ exit 1
107+ fi
108+
109+ # Expands short hex (#abc → aabbcc)
110+ if [[ ${# COLOR_HEX} -eq 3 ]]; then
111+ r=" ${COLOR_HEX: 0: 1} " ; g=" ${COLOR_HEX: 1: 1} " ; b=" ${COLOR_HEX: 2: 1} "
112+ COLOR_HEX=" ${r}${r}${g}${g}${b}${b} "
113+ fi
114+
115+ # Output file
116+ TIMESTAMP=$( date +%Y%m%d_%H%M%S_%N | cut -c1-22)
117+ OUTFILE=" fog_${WIDTH} x${HEIGHT} _${COLOR_HEX} _${TIMESTAMP} .png"
118+
119+ echo " Generating procedural fog..."
120+ echo " Dimension : ${WIDTH} x${HEIGHT} "
121+ echo " Color : #${COLOR_HEX} "
122+ echo " Output : ${OUTFILE} "
123+
124+ # ---------- generation in Python (uses venv interpreter) -------------------
125+ " ${py_bin} " - " $WIDTH " " $HEIGHT " " $COLOR_HEX " " $OUTFILE " << 'PYEOF '
126+ import sys, time
127+ import numpy as np
128+ from PIL import Image
129+ from scipy.ndimage import gaussian_filter
130+
131+ W = int(sys.argv[1])
132+ H = int(sys.argv[2])
133+ color = sys.argv[3]
134+ outf = sys.argv[4]
135+
136+ R = int(color[0:2], 16)
137+ G = int(color[2:4], 16)
138+ B = int(color[4:6], 16)
139+
140+ rng = np.random.default_rng(int(time.time_ns()) & 0xFFFFFFFFFFFFFFFF)
141+
142+ # =============================================================================
143+ # Memory-efficient fBm:
144+ # Noise generated at final size and smoothed with progressive Gaussian blur.
145+ # Large sigma = large structures (low frequency)
146+ # Small sigma = fine details (high frequency)
147+ # =============================================================================
148+ def make_fbm(W, H, octaves=8, base_sigma_frac=0.12, gain=0.50, lacunarity=2.0):
149+ total = np.zeros((H, W), dtype=np.float32)
150+ amp = 1.0
151+ sigma = max(4.0, min(W, H) * base_sigma_frac)
152+
153+ for _ in range(octaves):
154+ layer = rng.standard_normal((H, W)).astype(np.float32)
155+ if sigma >= 1.0:
156+ layer = gaussian_filter(layer, sigma=sigma)
157+ total += amp * layer
158+ amp *= gain
159+ sigma /= lacunarity
160+
161+ lo, hi = total.min(), total.max()
162+ total = (total - lo) / (hi - lo + 1e-9)
163+ return total
164+
165+ # Three layers with random sigmas
166+ s1 = float(rng.uniform(0.10, 0.22))
167+ s2 = float(rng.uniform(0.04, 0.10))
168+ s3 = float(rng.uniform(0.01, 0.04))
169+
170+ fbm1 = make_fbm(W, H, octaves=int(rng.integers(6, 9)), base_sigma_frac=s1)
171+ fbm2 = make_fbm(W, H, octaves=int(rng.integers(5, 8)), base_sigma_frac=s2)
172+ fbm3 = make_fbm(W, H, octaves=int(rng.integers(4, 7)), base_sigma_frac=s3)
173+
174+ w1 = float(rng.uniform(0.50, 0.70))
175+ w2 = float(rng.uniform(0.15, 0.30))
176+ w3 = 1.0 - w1 - w2
177+ fog = w1 * fbm1 + w2 * fbm2 + w3 * fbm3
178+ del fbm1, fbm2, fbm3
179+
180+ fog -= fog.min(); fog /= (fog.max() + 1e-9)
181+
182+ # =============================================================================
183+ # Sigmoid curve → alpha
184+ # =============================================================================
185+ mid = float(rng.uniform(0.32, 0.58))
186+ steep = float(rng.uniform(6.0, 13.0))
187+
188+ # sigmoid without overflow: clip exponent
189+ exponent = np.clip(-steep * (fog - mid), -88, 88).astype(np.float32)
190+ alpha = (1.0 / (1.0 + np.exp(exponent))).astype(np.float32)
191+ del fog, exponent
192+
193+ # Light smoothing
194+ alpha = gaussian_filter(alpha, sigma=max(1.0, min(W, H) * 0.003))
195+
196+ # Gamma
197+ gamma = float(rng.uniform(0.65, 1.05))
198+ np.power(alpha, gamma, out=alpha)
199+
200+ # Contrast redistribution
201+ p_lo = float(np.percentile(alpha, 4))
202+ p_hi = float(np.percentile(alpha, 96))
203+ if p_hi > p_lo:
204+ alpha = (alpha - p_lo) / (p_hi - p_lo)
205+ np.clip(alpha, 0.0, 1.0, out=alpha)
206+
207+ # =============================================================================
208+ # Cosine vignette on edges
209+ # =============================================================================
210+ fade_w = float(rng.uniform(0.05, 0.16))
211+
212+ def edge_curve(n, width):
213+ v = np.linspace(0.0, 1.0, n, dtype=np.float32)
214+ t = np.where(v < width, v / width,
215+ np.where(v > 1.0 - width, (1.0 - v) / width, 1.0))
216+ return ((1.0 - np.cos(t * np.pi)) * 0.5).astype(np.float32)
217+
218+ vx = edge_curve(W, fade_w) # (W,)
219+ vy = edge_curve(H, fade_w)[:, None] # (H,1)
220+ vignette = vx[None, :] * vy # broadcast (H,W)
221+ vignette = gaussian_filter(vignette, sigma=max(2.0, min(W, H) * 0.008))
222+
223+ alpha *= vignette
224+ del vignette
225+ np.clip(alpha, 0.0, 1.0, out=alpha)
226+
227+ # =============================================================================
228+ # Assembles RGBA and saves
229+ # =============================================================================
230+ alpha_u8 = (alpha * 255).astype(np.uint8)
231+ del alpha
232+
233+ rgba = np.stack([
234+ np.full((H, W), R, dtype=np.uint8),
235+ np.full((H, W), G, dtype=np.uint8),
236+ np.full((H, W), B, dtype=np.uint8),
237+ alpha_u8
238+ ], axis=-1)
239+ del alpha_u8
240+
241+ Image.fromarray(rgba, "RGBA").save(outf, "PNG")
242+ print(f"ok:{outf}")
243+ PYEOF
244+
245+ # ---------- checks output --------------------------------------------------
246+ if [[ -f " $OUTFILE " ]]; then
247+ SIZE=$( du -sh " $OUTFILE " | cut -f1)
248+ echo " "
249+ echo " Fog generated successfully!"
250+ echo " File : $OUTFILE "
251+ echo " Size : $SIZE "
252+ else
253+ echo " Failed to generate image." >&2
254+ exit 1
255+ fi
0 commit comments