Skip to content

Commit

Permalink
Apply wind field texture filter server-side
Browse files Browse the repository at this point in the history
Reduces download size and local cpu time.
  • Loading branch information
pgaskin committed Nov 18, 2023
1 parent 35662d0 commit 64357ed
Show file tree
Hide file tree
Showing 6 changed files with 153 additions and 97 deletions.
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,6 @@ The wind vector (in m/s) is extracted from the [GRIB2](https://www.nco.ncep.noaa

The elevation and wind vector magnitude range I chose seems to produce similar images as the old official one from 2019 (available at [`www.gstatic.com/pixel/livewallpaper/windy/gfs_wind_1000.jpg`](https://www.gstatic.com/pixel/livewallpaper/windy/gfs_wind_1000.jpg)), and the red/green/blue level curves are similar.

See [`windy.api.pgaskin.net/wind_field.jpg`](https://windy.api.pgaskin.net/wind_field.jpg) for the latest wind field image generated by this [code](./api/windy.go).
To create the texture passed to the particle system and background shaders, the image is scaled down to 1/4 of the size (i.e., 360x180) using bilinear filtering, then blurred using a gaussian kernel of radius 2. This matches what was done by the original live wallpaper. This filtering is done to smooth out the streamlines and remove local outlier values, resulting in less detailed and rounder wallpaper wind trails. Since the wallpaper still looks good, and is interesting in its own way before this filtering, I'm probably going to add variants with an unfiltered wind field later.

Before being passed to the particle system and background shaders, the image is scaled down to 1/4 of the size (i.e., 360x180) using bilinear filtering, then blurred using a gaussian kernel of radius 2. This matches what was done by the original live wallpaper. This filtering is done to smooth out the streamlines and remove local outlier values, resulting in less detailed and rounder wallpaper wind trails. Since the wallpaper still looks good, and is interesting in its own way before this filtering, I'm probably going to add variants with an unfiltered wind field later.
See [`windy.api.pgaskin.net/wind_field.jpg`](https://windy.api.pgaskin.net/wind_field.jpg) for the latest wind field image generated by this [code](./api/windy.go), and [`windy.api.pgaskin.net/wind_cache.png?filter=1`](https://windy.api.pgaskin.net/wind_cache.png?filter=1) for the latest filtered texture.
2 changes: 1 addition & 1 deletion api/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -110,7 +110,7 @@ func main() {
Handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if p, ok := strings.CutPrefix(r.URL.Path, "/"); ok {
if !strings.ContainsRune(p, '/') {
if strings.HasPrefix(p, "wind_field.") {
if strings.HasPrefix(p, "wind_field.") || strings.HasPrefix(p, "wind_cache.") {
switch r.Method {
case http.MethodGet, http.MethodHead:
windy.ServeHTTP(w, r)
Expand Down
165 changes: 144 additions & 21 deletions api/windy.go
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import (
"os"
"os/exec"
"path"
"slices"
"strconv"
"strings"
"sync"
Expand Down Expand Up @@ -70,6 +71,10 @@ type WindData struct {
Data []byte
ETag string
}
FilteredPNG [1]struct {
Data []byte
ETag string
}
Updated time.Time
Cycle gfsCycle
Source string
Expand Down Expand Up @@ -116,17 +121,35 @@ func (h *Windy) ServeHTTP(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Last-Modified", data.Updated.UTC().Format(http.TimeFormat))

var buf []byte
switch ext := path.Ext(r.URL.Path); ext {
case ".jpg":
switch base := path.Base(r.URL.Path); base {
case "wind_field.jpg":
w.Header().Set("Content-Type", "image/jpeg")
w.Header().Set("ETag", data.JPG.ETag)
buf = data.JPG.Data
case ".png":
w.Header().Set("Content-Type", "image/png")
case "wind_field.png":
w.Header().Set("Content-Type", "image/jpeg")
w.Header().Set("ETag", data.PNG.ETag)
buf = data.PNG.Data
case "wind_cache.png":
var v int
if ss := r.URL.Query()["filter"]; len(ss) != 1 {
http.Error(w, "Exactly one filter type (?filter=) is required.", http.StatusBadRequest)
return
} else if n, _ := strconv.ParseUint(ss[0], 10, 64); n == 0 {
http.Error(w, "Invalid filter type.", http.StatusBadRequest)
return
} else {
v = int(n)
}
if v > len(data.FilteredPNG) {
http.Error(w, "Unsupported filter type.", http.StatusBadRequest)
return
}
w.Header().Set("Content-Type", "image/png")
w.Header().Set("ETag", data.FilteredPNG[v-1].ETag)
buf = data.FilteredPNG[v-1].Data
default:
http.Error(w, "No image available for extension "+ext+".", http.StatusNotFound)
http.Error(w, "No image available for "+base+".", http.StatusNotFound)
return
}
http.ServeContent(w, r, "", data.Updated, bytes.NewReader(buf))
Expand Down Expand Up @@ -187,6 +210,7 @@ loop:

var output WindData
output.Updated = time.Now()
output.Updated = time.Date(2023, 11, 2, 4, 0, 0, 0, time.UTC)
output.Cycle = gfsCycle(output.Updated)

var wind [][][2]float64
Expand Down Expand Up @@ -247,7 +271,7 @@ loop:
for latIdx := range wind {
for lngIdx := range wind[latIdx] {
s, u, v := decompose(wind[latIdx][lngIdx])
img.Set(lngIdx, latIdx, color.RGBA{
img.SetRGBA(lngIdx, latIdx, color.RGBA{
R: uint8(mapValue(u, -1, 1, 0, 255)),
G: uint8(mapValue(v, -1, 1, 0, 255)),
B: uint8(mapValue(s, 0, 30, 0, 255)),
Expand All @@ -256,24 +280,46 @@ loop:
}
}
logger.Info("generated image, encoding")
{
var pngBuf bytes.Buffer
if err := png.Encode(&pngBuf, img); err != nil {
return nil, fmt.Errorf("encode png: %w", err)
}
output.PNG.Data = pngBuf.Bytes()

var pngBuf bytes.Buffer
if err := png.Encode(&pngBuf, img); err != nil {
return nil, fmt.Errorf("encode png: %w", err)
}
output.PNG.Data = pngBuf.Bytes()

var jpgBuf bytes.Buffer
if err := jpeg.Encode(&jpgBuf, img, &jpeg.Options{Quality: 100}); err != nil {
return nil, fmt.Errorf("encode png: %w", err)
}
output.JPG.Data = jpgBuf.Bytes()
var jpgBuf bytes.Buffer
if err := jpeg.Encode(&jpgBuf, img, &jpeg.Options{Quality: 100}); err != nil {
return nil, fmt.Errorf("encode png: %w", err)
}
output.JPG.Data = jpgBuf.Bytes()

pngSha := sha1.Sum(output.PNG.Data)
jpgSha := sha1.Sum(output.JPG.Data)
pngSha := sha1.Sum(output.PNG.Data)
jpgSha := sha1.Sum(output.JPG.Data)

output.PNG.ETag = "\"" + hex.EncodeToString(pngSha[:]) + "\""
output.JPG.ETag = "\"" + hex.EncodeToString(jpgSha[:]) + "\""
output.PNG.ETag = "\"" + hex.EncodeToString(pngSha[:]) + "\""
output.JPG.ETag = "\"" + hex.EncodeToString(jpgSha[:]) + "\""
}
for i, filter := range []func(*image.RGBA){
func(img *image.RGBA) {
bilinear4(img)
gaussian5(img)
},
} {
logger.Info("generating pre-filtered texture", "filter", i+1)

old := img
img := image.NewRGBA(old.Rect)
img.Pix = slices.Clone(old.Pix)
filter(img)

var buf bytes.Buffer
if err := png.Encode(&buf, img); err != nil {
return nil, fmt.Errorf("encode png: %w", err)
}
output.FilteredPNG[i].Data = buf.Bytes()
sha := sha1.Sum(output.FilteredPNG[i].Data)
output.FilteredPNG[i].ETag = "\"" + hex.EncodeToString(sha[:]) + "\""
}

return &output, nil
}); err != nil {
Expand All @@ -287,6 +333,83 @@ loop:
}
}

// gaussian5 does a gaussian blur with a 5x5 kernel.
func gaussian5(img *image.RGBA) {
if img.Stride != 4*img.Rect.Max.X {
panic("wtf")
}
tmp := make([]uint8, len(img.Pix))
gaussian5k(tmp, img.Pix, img.Rect.Max.X, img.Rect.Max.Y)
gaussian5k(img.Pix, tmp, img.Rect.Max.Y, img.Rect.Max.X)
}

// gaussian5k does a gaussian blur with a 5x5 kernel along the x-axis on a
// row-major RGBA image, transposing the result.
func gaussian5k(out, in []uint8, w, h int) {
kernel := [...]uint32{
// gaussian blur kernel (radius 2)
// note: 65536 = 2^16
// note: sum is 65534/65536, so it's close enough (it must not be more, though, or pixels will overflow)
uint32(math.Trunc(65536 * 0.06136)),
uint32(math.Trunc(65536 * 0.24477)),
uint32(math.Trunc(65536 * 0.38774)),
uint32(math.Trunc(65536 * 0.24477)),
uint32(math.Trunc(65536 * 0.06136)),
}
for y := 0; y < h; y++ {
for x := 0; x < w; x++ {
var c [4]uint32
for ki, k := range kernel {
r := (len(kernel) - 1) / 2
x := min(max((ki-r)+x, 0), w-1)
ip := in[(y*w+x)*len(c):][:len(c):len(c)]
for i := range c {
c[i] += (uint32(ip[i]) + 1) * k // +1 so 255 = 255 in the result
}
}
p := out[(x*h+y)*len(c):][:len(c):len(c)] // transposed
for i := range c {
p[i] = uint8(c[i] >> 16)
}
}
}
}

// bilinear4 scales img by 0.25 using a bilinear filter (essentially a box
// filter since 4 is a power of 2).
func bilinear4(img *image.RGBA) {
if img.Stride != 4*img.Rect.Max.X {
panic("wtf")
}
tmp := make([]uint8, len(img.Pix)/4)
img.Stride /= 4 // 1/4
bilinear4k(tmp, img.Pix, img.Rect.Max.X, img.Rect.Max.Y)
img.Rect.Max.X /= 4 // 1/4
bilinear4k(img.Pix, tmp, img.Rect.Max.Y, img.Rect.Max.X)
img.Rect.Max.Y /= 4 // 1/4
}

// bilinear4k scales img by 0.25 using a bilinear filter along the x-axis on a
// row-major RGBA image, transposing the result.
func bilinear4k(out, in []uint8, w, h int) {
for y := 0; y < h; y++ {
for x := 0; x < w/4; x++ { // 1/4
var c [4]uint32
for i := 0; i < 4; i++ { // 1/4
x := x*4 + i
ip := in[(y*w+x)*len(c):][:len(c):len(c)]
for i := range c {
c[i] += uint32(ip[i])
}
}
p := out[(x*h+y)*len(c):][:len(c):len(c)] // transposed
for i := range c {
p[i] = uint8(c[i] >> 2) // 1/2^2 == 1/4
}
}
}
}

// zeroDef returns def if val is zero, and val otherwise.
func zeroDef[T comparable](val, def T) T {
var zero T
Expand Down
1 change: 0 additions & 1 deletion app/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,6 @@ android {
versionName "5"

buildConfigField "String", "WIND_FIELD_API_HOST", "\"windy.api.pgaskin.net\""
buildConfigField "long", "WIND_FIELD_SIZE_ESTIMATED", "1500" // kB
buildConfigField "long", "WIND_FIELD_UPDATE_INTERVAL", "360" // min
buildConfigField "long", "WIND_FIELD_UPDATE_INTERVAL_MINIMUM", "15" // min
buildConfigField "boolean", "SAVE_SCREENSHOTS", "false"
Expand Down
74 changes: 4 additions & 70 deletions app/src/main/java/net/pgaskin/windy/WindField.java
Original file line number Diff line number Diff line change
Expand Up @@ -9,10 +9,8 @@

import com.badlogic.gdx.graphics.Pixmap;
import com.badlogic.gdx.graphics.Texture;
import com.badlogic.gdx.math.MathUtils;

import java.io.File;
import java.io.FileOutputStream;
import java.io.InputStream;
import java.nio.file.Files;
import java.nio.file.StandardCopyOption;
Expand Down Expand Up @@ -64,84 +62,20 @@ private static File windCacheFile(Context context, boolean temp) {
public static void updateCache(Context context, InputStream src) throws Exception {
Log.i(TAG, "updating cached field pixmap");

final BitmapFactory.Options cfg = new BitmapFactory.Options();
cfg.inPreferredConfig = Bitmap.Config.ARGB_8888;
Bitmap img = BitmapFactory.decodeStream(src, null, cfg);
Files.copy(src, windCacheFile(context, true).toPath(), StandardCopyOption.REPLACE_EXISTING);

final Bitmap img = BitmapFactory.decodeFile(windCacheFile(context, true).toString());
if (img == null) {
throw new Exception("Failed to decode input bitmap");
}
if (img.getConfig() != Bitmap.Config.ARGB_8888) {
throw new Exception("Input bitmap was not decoded as ARGB8888");
}

img = Bitmap.createScaledBitmap(img, img.getWidth() / 4, img.getHeight() / 4, true);
img = blur(img);

try (final FileOutputStream tmp = new FileOutputStream(windCacheFile(context, true))) {
if (!img.compress(Bitmap.CompressFormat.PNG, 100, tmp)) {
throw new Exception("Failed to encode scaled bitmap");
}
}
Files.move(windCacheFile(context, true).toPath(), windCacheFile(context, false).toPath(), StandardCopyOption.ATOMIC_MOVE);

synchronized (currentBitmapLock) {
if (currentBitmap != null) {
currentBitmap.recycle();
}
currentBitmap = img;
currentSeq.addAndGet(1);
}
}

private static Bitmap blur(Bitmap src) {
final int w = src.getWidth();
final int h = src.getHeight();
final int[] a = new int[w*h];
final int[] b = new int[w*h];
src.getPixels(a, 0, w, 0, 0, w, h); // ARGB8888
for (int x = 0; x < w; x++) {
for (int y = 0; y < h; y++) {
blurKernel(a, b, x, y, w, h, false);
}
}
for (int x = 0; x < w; x++) {
for (int y = 0; y < h; y++) {
blurKernel(b, a, x, y, w, h, true);
}
}
if (!src.isMutable()) {
src = Bitmap.createBitmap(w, h, src.getConfig());
}
src.setPixels(b, 0, w, 0, 0, w, h);
return src;
}

private static void blurKernel(int[] in, int[] out, int x, int y, int w, int h, boolean vertical) {
final int[] KERNEL = {
// gaussian blur kernel (radius 2)
// note: 65536 = 2^16
// note: sum is 65534/65536, so it's close enough (it must not be more, though, or pixels will overflow)
(int)(65536f * 0.06136f),
(int)(65536f * 0.24477f),
(int)(65536f * 0.38774f),
(int)(65536f * 0.24477f),
(int)(65536f * 0.06136f),
};
int r = 0, g = 0, b = 0;
for (int ki = 0, i = (vertical?y:x) - (KERNEL.length-1)/2; ki < KERNEL.length; i++, ki++) {
int px = vertical ? x : MathUtils.clamp(i, 0, w-1);
int py = vertical ? MathUtils.clamp(i, 0, h-1) : y;
int pc = in[py*w + px];
int kv = KERNEL[ki];
r += kv * (0xFF & (pc >>> 16));
g += kv * (0xFF & (pc >>> 8));
b += kv * (0xFF & (pc));
}
int pc = 0;
pc |= 0xFF << 24;
pc |= ((r >>> 16) << 16);
pc |= ((g >>> 16) << 8);
pc |= ((b >>> 16));
out[y*w + x] = pc;
Files.move(windCacheFile(context, true).toPath(), windCacheFile(context, false).toPath(), StandardCopyOption.ATOMIC_MOVE);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,7 @@ public boolean onStartJob(JobParameters params) {
}

final NetworkCapabilities cap = this.getSystemService(ConnectivityManager.class).getNetworkCapabilities(net);
final URL url = new URL("https", BuildConfig.WIND_FIELD_API_HOST, "/wind_field.jpg");
final URL url = new URL("https", BuildConfig.WIND_FIELD_API_HOST, "/wind_cache.png?filter=1");
Log.i(TAG, "updating wind field from " + url + " using network " + net + " with capabilities " + cap);

if (!cap.hasCapability(NetworkCapabilities.NET_CAPABILITY_NOT_VPN)) {
Expand Down Expand Up @@ -144,7 +144,7 @@ private static boolean schedule(Context context, int jobID) {
throw new IllegalArgumentException("Unknown jobID");
}
builder.setRequiredNetworkType(JobInfo.NETWORK_TYPE_UNMETERED);
builder.setEstimatedNetworkBytes(BuildConfig.WIND_FIELD_SIZE_ESTIMATED * 1000, 0);
builder.setEstimatedNetworkBytes(256 * 1000, 0);
builder.setBackoffCriteria(BuildConfig.WIND_FIELD_UPDATE_INTERVAL_MINIMUM * 60 * 1000, JobInfo.BACKOFF_POLICY_EXPONENTIAL);

final JobScheduler scheduler = context.getSystemService(JobScheduler.class);
Expand Down

0 comments on commit 64357ed

Please sign in to comment.