Compare commits

...

10 commits

Author SHA1 Message Date
c8d5637e25
meta: update sample config for prefix syntax & output maching
Signed-off-by: NotAShelf <raf@notashelf.dev>
Change-Id: I4ef49c171e260fe8e1ea114a58af77166a6a6964
2026-04-16 16:03:34 +03:00
459989e896
config: add description-based output matching with desc: prefix
Signed-off-by: NotAShelf <raf@notashelf.dev>
Change-Id: Idbe0661f0c491512d61ace4337ebe8cd6a6a6964
2026-04-16 16:03:33 +03:00
e871307f6a
chore: add more Make tasks for tests & benchmarks
Signed-off-by: NotAShelf <raf@notashelf.dev>
Change-Id: I5cabfcf1815588ffec6c8b865cd163176a6a6964
2026-04-16 16:03:32 +03:00
000258df5c
chore: ignore valgrind artifacts
Signed-off-by: NotAShelf <raf@notashelf.dev>
Change-Id: I309e751b96e858f231e224d3b345670e6a6a6964
2026-04-16 16:03:31 +03:00
4a84ed7a21
scripts: visualise benchmark results via Python script
Signed-off-by: NotAShelf <raf@notashelf.dev>
Change-Id: If48e0a1c4b265946c009b3abd9a249a96a6a6964
2026-04-16 16:03:30 +03:00
9be9c8276a
lib: add test helpers
Signed-off-by: NotAShelf <raf@notashelf.dev>
Change-Id: Iaefc0d503288b4ffe8e6922130acc2ec6a6a6964
2026-04-16 16:03:29 +03:00
4d10074181
nix: streamline packaging
Signed-off-by: NotAShelf <raf@notashelf.dev>
Change-Id: I4643ed2c8e6f8ceb5e722612cc67a74e6a6a6964
2026-04-16 16:03:28 +03:00
5df01492ec
docs: simplify README
Signed-off-by: NotAShelf <raf@notashelf.dev>
Change-Id: I893d51c6a084a0ed56a27cf0bcfae14b6a6a6964
2026-04-16 16:03:27 +03:00
23527908c2
config: fix wording
Signed-off-by: NotAShelf <raf@notashelf.dev>
Change-Id: I41e3e89470fa8181d887de0584c965176a6a6964
2026-04-16 16:03:26 +03:00
3db813fcc2
tests: initial unit testing
Signed-off-by: NotAShelf <raf@notashelf.dev>
Change-Id: Ib67a52ddcdbb9d5378dc3dd2dd7b5d106a6a6964
2026-04-16 16:03:25 +03:00
11 changed files with 993 additions and 69 deletions

2
.gitignore vendored
View file

@ -12,3 +12,5 @@ test_memory
test_config
*.jpg
*.conf
vgcore.*
*_report.txt

View file

@ -131,11 +131,12 @@ clean:
@echo "Cleaning build artifacts..."
rm -rf $(OBJDIR) $(BINDIR)
rm -f $(PROTOCOL_HEADERS) $(PROTOCOL_SOURCES)
rm -f "vcore.*"
# Format source code (requires clang-format)
format:
@echo "Formatting source code..."
@find $(SRCDIR) $(INCDIR) -name "*.c" -o -name "*.h" | xargs clang-format -i
@find $(SRCDIR) -name "*.c" -o -name "*.h" | xargs clang-format -i
# Static analysis (requires cppcheck)
analyze:
@ -147,10 +148,33 @@ analyze:
$(SRCDIR)
# Run tests
# FIXME: add tests
test: $(TARGET)
@echo "Running tests..."
@echo "Tests not implemented yet."
@echo "Running unit tests..."
@$(CC) -o bin/test tests/test.c lib/test_common.c \
-I./include -I./tests -I./tests/util -lm -std=c11 -D_GNU_SOURCE $(CFLAGS)
@./bin/test
# Run benchmarks
bench:
@echo "Running performance benchmarks..."
@$(CC) -o bin/bench benchmarks/bench.c lib/test_common.c \
-I./include -I./tests -I./tests/util -lm -std=c11 -D_GNU_SOURCE $(CFLAGS)
@./bin/bench
# Memory analysis tests
test-memory:
@echo "Building memory tests..."
@$(CC) -o bin/test tests/test.c lib/test_common.c \
-I./include -I./tests -I./tests/util -lm -std=c11 -D_GNU_SOURCE $(CFLAGS)
@valgrind --leak-check=full --show-leak-kinds=all ./bin/test 2>&1 | tee tests/memory_report.txt
@echo "Analysis complete. See tests/memory_report.txt"
# Generate memory profile CSVs
profile-memory:
@$(CC) -o bin/test tests/test.c lib/test_common.c \
-I./include -I./tests -I./tests/util -lm -std=c11 -D_GNU_SOURCE $(CFLAGS)
@./bin/test --profile
@echo "CSV files generated in /tmp/"
# Version management targets
bump-patch:
@ -213,7 +237,7 @@ help:
-include $(DEPENDS)
# Phony targets
.PHONY: all debug static check-deps install uninstall systemd-service version-header sample-config clean distclean format analyze test help bump-patch bump-minor bump-major set-version
.PHONY: all debug static check-deps install uninstall systemd-service version-header clean distclean format analyze test test-memory memory-report help bump-patch bump-minor bump-major set-version
# Print variables
print-%:

View file

@ -53,6 +53,9 @@ comprehensive monitor management. Here's what makes Chroma stand out:
- Wayland development headers
- EGL/OpenGL development headers
See [development section](#development) for more details. This section might get
outdated at any given moment, so refer to the Nix shell if in doubt.
### Building
#### Quick Build
@ -66,6 +69,9 @@ make
# Or build debug version
make debug
# Alternatively, create a static build
make static
```
### Installation
@ -179,44 +185,56 @@ Chroma works with any Wayland compositor that supports:
- `wl_output` interface
- EGL window surface creation
Tested only on Hyprland.
Tested only on Hyprland, but should work fine with any compositor that meets the
above criteria. Which is basically all of them I think?
## Development
## Contributing
### Building Debug Version
You might want to contribute to Chroma for a variety of reasons. I usually will
not judge, however, there are some conventions I expect you to adhere to. Mainly
I would like for you to follow the project's **code style**:
- C11 standard (I really wished for C99)
- 2-space indentation (use `make format`)
- No tabs (except for the Makefile, obviously)
- Function names: `chroma_function_name`
- Constants: `CHROMA_CONSTANT_NAME`
Once your changes are done, fork this repository and create a feature branch.
This is not a strict requirement but I'd rather not deal with rebase failures.
Create your feature branch, make your changes, _test thoroughly_ and submit your
pull request when you are done. With your pull request, I'd _really_ like a tiny
snippet of text that explains your motive of changes. While I can infer what you
are trying to do, I'd rather _know_ what was going on in your head.
### Development
A Nix shell is provided within the repository. You may use both `nix-shell` and
`nix develop` to enter a development shell with all of the required dependencies
for _dynamic linking_. Additionally, [Direnv](https://direnv.net) users may use
`direnv allow` to use the shell provided by the repository.
A few convenience commands are provided by the Makefile, which you may invoke at
your own discretion.
#### Building Debug Version
```bash
make debug
```
### Code Formatting
#### Code Formatting
```bash
make format # requires clang-format
```
### Static Analysis
#### Static Analysis
```bash
make analyze # requires cppcheck
```
## Contributing
1. Fork the repository
2. Create a feature branch
3. Make your changes
4. Test thoroughly
5. Submit a pull request
### Code Style
- C11 standard
- 2-space indentation
- No tabs (except for the Makefile, obviously)
- Function names: `chroma_function_name`
- Constants: `CHROMA_CONSTANT_NAME`
## License
<!--markdownlint-disable MD059 -->

View file

@ -52,7 +52,7 @@ anchor_x = 50
anchor_y = 50
# Image downsampling settings for performance optimization
# ===================================================
# ========================================================
# Enable automatic downsampling of large images to save memory and improve performance
# Set to false to keep original resolution for all images (uses more memory!)
enable_downsampling = true
@ -73,6 +73,21 @@ min_scale_factor = 0.25 # Don't scale below 25% of original size
# ==================================
# Basic format: output.OUTPUT_NAME = /path/to/image.ext
#
# You can match outputs by name OR by description:
# output.DP-1 = /path/to/image.jpg # Match by port name
# output.desc:Samsung = /path/to/image.jpg # Match by description prefix
#
# The description is the human-readable name provided by the compositor
# via the Wayland wl_output description event. For example, if your
# monitor reports "Samsung T27A450" as its description, you can use
# "output.desc:Samsung" to match it. The match is a prefix match, so
# "output.desc:Sam" would also work.
#
# To find your output names and descriptions, run one of these commands:
# wlr-randr (for wlroots-based compositors)
# wayland-info | grep wl_output
# kanshi list-outputs
#
# Extended format with per-output settings:
# output.OUTPUT_NAME = /path/to/image.ext
# output.OUTPUT_NAME.scale = fill|fit|stretch|center
@ -90,23 +105,6 @@ min_scale_factor = 0.25 # Don't scale below 25% of original size
# bottom-left - image anchored to bottom-left corner
# bottom-right - image anchored to bottom-right corner
#
# To find your output names, run one of these commands:
#
# Compositor Agnostic:
# - wlr-randr (for wlroots-based compositors)
# - wayland-info | grep wl_output
# - kanshi list-outputs
#
# Compositor Specific
# - hyprctl monitors -j | jq .[].name (Hyprland specific)
#
# Common output name patterns:
# - DP-1, DP-2, DP-3, etc. (DisplayPort)
# - HDMI-A-1, HDMI-A-2, etc. (HDMI)
# - eDP-1 (embedded DisplayPort, laptops)
# - DVI-D-1, DVI-I-1 (DVI)
# - VGA-1 (VGA, legacy)
#
# Examples:
# output.HDMI-A-1 = ~/Pictures/wallpaper.jpg
# output.DP-1 = ~/Pictures/monitor1.png
@ -115,6 +113,14 @@ min_scale_factor = 0.25 # Don't scale below 25% of original size
# output.DP-1.anchor = top-left
# output.DP-2 = ~/Pictures/monitor2.jpg
# output.DP-2.scale = stretch
#
# # Match by monitor description (prefix match):
# output.desc:Samsung = ~/Pictures/samsung-wallpaper.jpg
# output.desc:Samsung.scale = fill
# output.desc:LG Ultra = ~/Pictures/lg-wallpaper.jpg
# output.desc:BenQ = ~/Pictures/benq-wallpaper.jpg
#
# # Laptop internal display:
# output.eDP-1 = ~/Pictures/laptop-wallpaper.jpg
# output.eDP-1.scale = fill
# output.eDP-1.anchor = bottom-right

8
include/chroma.h vendored
View file

@ -250,11 +250,13 @@ void chroma_images_cleanup(chroma_state_t *state);
int chroma_config_load(chroma_config_t *config, const char *config_file);
void chroma_config_free(chroma_config_t *config);
const char *chroma_config_get_image_for_output(chroma_config_t *config,
const char *output_name);
const char *output_name,
const char *output_description);
int chroma_config_get_mapping_for_output(
chroma_config_t *config, const char *output_name,
chroma_scale_mode_t *scale_mode, chroma_filter_quality_t *filter_quality,
chroma_anchor_t *anchor, float *anchor_x, float *anchor_y);
const char *output_description, chroma_scale_mode_t *scale_mode,
chroma_filter_quality_t *filter_quality, chroma_anchor_t *anchor,
float *anchor_x, float *anchor_y);
void chroma_config_print(const chroma_config_t *config);

190
lib/test_common.c Normal file
View file

@ -0,0 +1,190 @@
#define STB_IMAGE_IMPLEMENTATION
#define STB_IMAGE_WRITE_IMPLEMENTATION
#include "test_common.h"
#include "stb_image.h"
#include "stb_image_write.h"
#include <math.h>
#include <sys/time.h>
uint8_t *load_image(const char *path, int *width, int *height, int *channels) {
return stbi_load(path, width, height, channels, 4);
}
int save_image(const char *path, uint8_t *data, int width, int height, int channels) {
return stbi_write_png(path, width, height, channels, data, width * channels);
}
uint8_t *downsample_image(uint8_t *src, int sw, int sh, int sc, int *dw, int *dh, float scale) {
if (!src || sw <= 0 || sh <= 0 || sc <= 0 || scale <= 0) {
if (dw) *dw = 0;
if (dh) *dh = 0;
return NULL;
}
int tw = (int)(sw * scale);
int th = (int)(sh * scale);
if (tw < 1) tw = 1;
if (th < 1) th = 1;
uint8_t *dst = malloc(tw * th * sc);
if (!dst) return NULL;
float inv_scale = 1.0f / scale;
for (int y = 0; y < th; y++) {
for (int x = 0; x < tw; x++) {
float sx = (x + 0.5f) * inv_scale;
float sy = (y + 0.5f) * inv_scale;
int ix = (int)sx;
int iy = (int)sy;
if (sw > 1) {
ix = (ix < sw - 2) ? ix : sw - 2;
} else {
ix = 0;
}
if (sh > 1) {
iy = (iy < sh - 2) ? iy : sh - 2;
} else {
iy = 0;
}
if (ix < 0) ix = 0;
if (iy < 0) iy = 0;
float fx = sx - ix;
float fy = sy - iy;
if (sw == 1) fx = 0.0f;
if (sh == 1) fy = 0.0f;
for (int c = 0; c < sc; c++) {
uint8_t p00 = src[(iy * sw + ix) * sc + c];
uint8_t p01 = (sw > 1) ? src[(iy * sw + ix + 1) * sc + c] : p00;
uint8_t p10 = (sh > 1) ? src[((iy + 1) * sw + ix) * sc + c] : p00;
uint8_t p11 = (sw > 1 && sh > 1) ? src[((iy + 1) * sw + ix + 1) * sc + c] : p00;
float interp = p00 * (1 - fx) * (1 - fy) +
p01 * fx * (1 - fy) +
p10 * (1 - fx) * fy +
p11 * fx * fy;
dst[(y * tw + x) * sc + c] = (uint8_t)(interp + 0.5f);
}
}
}
*dw = tw;
*dh = th;
return dst;
}
double get_time_ms(void) {
struct timeval tv;
gettimeofday(&tv, NULL);
return tv.tv_sec * 1000.0 + tv.tv_usec / 1000.0;
}
double run_benchmark(double (*fn)(void), int iterations) {
double warmup = fn();
(void)warmup;
double total = 0.0;
for (int i = 0; i < iterations; i++) {
total += fn();
}
return total / iterations;
}
int compare_images(uint8_t *a, uint8_t *b, int w, int h, int ch, float threshold) {
int max_diff = 0;
for (int i = 0; i < w * h * ch; i++) {
int diff = abs((int)a[i] - (int)b[i]);
if (diff > max_diff) max_diff = diff;
if (diff > (int)(threshold * 255.0f)) {
return 0;
}
}
return 1;
}
float compute_psnr(uint8_t *a, uint8_t *b, int w, int h, int ch) {
double mse = 0.0;
int total = w * h * ch;
for (int i = 0; i < total; i++) {
double diff = (double)a[i] - (double)b[i];
mse += diff * diff;
}
mse /= total;
if (mse < 1e-10) return 99.99f;
return (float)(10.0 * log10(255.0 * 255.0 / mse));
}
uint8_t *create_gradient_image(int w, int h) {
uint8_t *img = malloc(w * h * 4);
if (!img) return NULL;
for (int y = 0; y < h; y++) {
for (int x = 0; x < w; x++) {
int idx = (y * w + x) * 4;
img[idx + 0] = (uint8_t)((float)x / w * 255);
img[idx + 1] = (uint8_t)((float)y / h * 255);
img[idx + 2] = 128;
img[idx + 3] = 255;
}
}
return img;
}
uint8_t *create_noise_image(int w, int h, unsigned int seed) {
uint8_t *img = malloc(w * h * 4);
if (!img) return NULL;
srand(seed);
for (int i = 0; i < w * h * 4; i++) {
img[i] = (uint8_t)(rand() % 256);
}
return img;
}
uint8_t *create_uniform_image(int w, int h, uint8_t r, uint8_t g, uint8_t b) {
uint8_t *img = malloc(w * h * 4);
if (!img) return NULL;
for (int i = 0; i < w * h; i++) {
img[i * 4 + 0] = r;
img[i * 4 + 1] = g;
img[i * 4 + 2] = b;
img[i * 4 + 3] = 255;
}
return img;
}
uint8_t *create_checkerboard(int w, int h, int check_size) {
uint8_t *img = malloc(w * h * 4);
if (!img) return NULL;
for (int y = 0; y < h; y++) {
for (int x = 0; x < w; x++) {
int idx = (y * w + x) * 4;
int cx = x / check_size;
int cy = y / check_size;
if ((cx + cy) % 2 == 0) {
img[idx + 0] = 255;
img[idx + 1] = 255;
img[idx + 2] = 255;
} else {
img[idx + 0] = 0;
img[idx + 1] = 0;
img[idx + 2] = 0;
}
img[idx + 3] = 255;
}
}
return img;
}

View file

@ -15,7 +15,7 @@
in
stdenv.mkDerivation {
pname = "chroma";
version = "1.0.0";
version = "1.0.1";
src = fs.toSource {
root = s;
@ -23,8 +23,11 @@ in
(s + /include)
(s + /protocols)
(s + /src)
(s + /Makefile)
# For testing
(s + /lib)
(s + /tests)
];
};
@ -49,9 +52,17 @@ in
makeFlags = [
"PREFIX=$(out)"
"SYSTEMD_DIR=$(out)/lib/systemd/system"
"SYSTEMD_DIR=$(out)/lib/systemd/system" # FIXME: this is an user service, actually
];
checkPhase = ''
runHook preCheck
make test
runHook postCheck
'';
postInstall = ''
install -Dm755 ${../chroma.conf.sample} $out/share/chroma.conf.sample
'';

378
scripts/generate_report.py Normal file
View file

@ -0,0 +1,378 @@
#!/usr/bin/env python3
import csv
import os
import sys
from datetime import datetime
from pathlib import Path
try:
import matplotlib.pyplot as plt
import numpy as np
HAS_MATPLOTLIB = True
except ImportError:
HAS_MATPLOTLIB = False
CSV_DIR = Path("/tmp")
OUTPUT_DIR = Path("/tmp")
RESOLUTIONS = ["1080p", "1440p", "4K", "5K", "6K", "8K"]
SCENARIOS = [
("No_Downsampling", "No Downsampling"),
("1080p_Target", "1080p Target"),
("1440p_Target", "1440p Target"),
("4K_Target", "4K Target"),
]
def load_csv_data(filename: str) -> list[dict]:
"""Load data from CSV file."""
data = []
with open(filename, "r") as f:
reader = csv.DictReader(f)
for row in reader:
data.append(row)
return data
def extract_value(csv_file: str, resolution: str, column: str) -> str | None:
"""Extract a value from CSV for a given resolution and column."""
if not os.path.exists(csv_file):
return None
data = load_csv_data(csv_file)
for row in data:
if row.get("Resolution") == resolution:
return row.get(column)
return None
def generate_text_report() -> str:
"""Generate a text-based report."""
lines = []
lines.append("Chroma Memory Impact Analysis Report")
lines.append("=" * 44)
lines.append(f"Generated on: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}")
lines.append("")
lines.append("=== Memory Usage Summary ===")
lines.append("")
lines.append(
f"{'Input':<8} {'Original':<12} {'Downsampled':<12} {'Savings':<10} {'Downsampled?':<12}"
)
lines.append(f"{'Res':<8} {'Size (MB)':<12} {'Size (MB)':<12} {'(%)':<10} {'':12}")
lines.append("-" * 56)
for res in RESOLUTIONS:
original = extract_value(
str(CSV_DIR / "chroma_memory_No_Downsampling.csv"), res, "OriginalSizeMB"
)
downsampled = extract_value(
str(CSV_DIR / "chroma_memory_4K_Target.csv"), res, "DownsampledSizeMB"
)
savings = extract_value(
str(CSV_DIR / "chroma_memory_4K_Target.csv"), res, "MemorySavingsPercent"
)
if original:
orig_mb = float(original)
down_mb = float(downsampled) if downsampled else orig_mb
sav_pct = float(savings) if savings else 0.0
downsampled_yes = "Yes" if sav_pct > 0 else "No"
lines.append(
f"{res:<8} {orig_mb:<12.2f} {down_mb:<12.2f} {sav_pct:<10.1f} {downsampled_yes:<12}"
)
lines.append("")
lines.append("=== Key Findings ===")
lines.append("")
lines.append("Memory Savings by Scenario (4K images):")
lines.append("")
for name, display_name in SCENARIOS[1:]:
csv_path = CSV_DIR / f"chroma_memory_{name}.csv"
savings = extract_value(str(csv_path), "4K", "MemorySavingsPercent")
if savings:
lines.append(f" {display_name:<20}: {float(savings):>6.1f}%")
lines.append("")
lines.append("=== Impact on Typical Usage ===")
lines.append("")
lines.append("Scenario: User with 5 wallpapers, mixed resolutions")
lines.append("")
lines.append("Without downsampling: 5 × 31.6 MB = 158.2 MB")
lines.append("With 4K target: 5 × 7.9 MB = 39.6 MB")
lines.append("Memory saved: 118.6 MB (75.0%)")
lines.append("")
lines.append("=== Recommendations ===")
lines.append("")
lines.append("1. Enable downsampling for systems with < 8GB RAM")
lines.append("2. Use 4K target for most users (good balance)")
lines.append("3. Use 1080p target for low-memory systems")
lines.append("4. Disable downsampling only for systems with > 16GB RAM")
lines.append("5. Adjust min_scale_factor to preserve detail when needed")
lines.append("")
lines.append("=== Configuration Examples ===")
lines.append("")
lines.append("# Maximum Performance (low memory)")
lines.append("enable_downsampling = true")
lines.append("max_output_width = 1920")
lines.append("max_output_height = 1080")
lines.append("min_scale_factor = 0.5")
lines.append("")
lines.append("# Balanced (default)")
lines.append("enable_downsampling = true")
lines.append("max_output_width = 3840")
lines.append("max_output_height = 2160")
lines.append("min_scale_factor = 0.25")
lines.append("")
lines.append("# Maximum Quality")
lines.append("enable_downsampling = false")
lines.append("")
lines.append("=== Raw Data ===")
lines.append("")
lines.append("CSV files available at:")
csv_files = list(CSV_DIR.glob("chroma_memory_*.csv"))
if csv_files:
for f in sorted(csv_files):
lines.append(f" {f}")
else:
lines.append(" No CSV files found")
lines.append("")
lines.append("Run 'make profile-memory' to regenerate data.")
return "\n".join(lines)
def create_memory_comparison_graph():
"""Create memory comparison graph for all scenarios."""
if not HAS_MATPLOTLIB:
raise ImportError("matplotlib not available")
plt.figure(figsize=(12, 8))
colors = ["#FF6B6B", "#4ECDC4", "#45B7D1", "#96CEB4"]
patterns = ["/", "\\", "|", "-"]
x = np.arange(len(RESOLUTIONS))
width = 0.2
for i, (scenario_key, scenario_name) in enumerate(SCENARIOS):
csv_path = CSV_DIR / f"chroma_memory_{scenario_key}.csv"
if csv_path.exists():
data = load_csv_data(str(csv_path))
original_sizes = []
downsampled_sizes = []
for res in RESOLUTIONS:
row = next((r for r in data if r.get("Resolution") == res), None)
if row:
original_sizes.append(float(row.get("OriginalSizeMB", 0)))
downsampled_sizes.append(float(row.get("DownsampledSizeMB", 0)))
else:
original_sizes.append(0)
downsampled_sizes.append(0)
offset = i * width
plt.bar(
x + offset,
original_sizes,
width,
label=f"{scenario_name} - Original",
color=colors[i],
alpha=0.7,
)
plt.bar(
x + offset,
downsampled_sizes,
width,
label=f"{scenario_name} - Downsampled",
color=colors[i],
alpha=0.9,
hatch=patterns[i],
)
plt.xlabel("Input Resolution")
plt.ylabel("Memory Usage (MB)")
plt.title("Chroma Memory Usage: Original vs Downsampled")
plt.xticks(x + width * 1.5, RESOLUTIONS)
plt.legend(bbox_to_anchor=(1.05, 1), loc="upper left")
plt.grid(True, alpha=0.3)
plt.tight_layout()
plt.savefig(
OUTPUT_DIR / "chroma_memory_comparison.png", dpi=300, bbox_inches="tight"
)
plt.close()
def create_savings_graph():
"""Create memory savings percentage graph."""
if not HAS_MATPLOTLIB:
raise ImportError("matplotlib not available")
plt.figure(figsize=(10, 6))
colors = ["#FF6B6B", "#4ECDC4", "#45B7D1"]
markers = ["o", "s", "^"]
for i, (scenario_key, scenario_name) in enumerate(SCENARIOS[1:]):
csv_path = CSV_DIR / f"chroma_memory_{scenario_key}.csv"
if csv_path.exists():
data = load_csv_data(str(csv_path))
resolutions = []
savings = []
for row in data:
pct = row.get("MemorySavingsPercent", "0")
try:
if float(pct) > 0:
resolutions.append(row.get("Resolution", ""))
savings.append(float(pct))
except ValueError:
continue
plt.plot(
resolutions,
savings,
marker=markers[i],
color=colors[i],
linewidth=2,
markersize=8,
label=scenario_name,
)
plt.xlabel("Input Resolution")
plt.ylabel("Memory Savings (%)")
plt.title("Memory Savings by Input Resolution and Target")
plt.grid(True, alpha=0.3)
plt.legend()
plt.tight_layout()
plt.savefig(OUTPUT_DIR / "chroma_savings.png", dpi=300, bbox_inches="tight")
plt.close()
def create_summary_table():
"""Create a summary table image."""
if not HAS_MATPLOTLIB:
raise ImportError("matplotlib not available")
fig, ax = plt.subplots(figsize=(10, 6))
ax.axis("tight")
ax.axis("off")
scenario_names = [name for _, name in SCENARIOS]
table_data = []
for res in RESOLUTIONS:
row = [res]
for scenario_key, _ in SCENARIOS:
csv_path = CSV_DIR / f"chroma_memory_{scenario_key}.csv"
if csv_path.exists():
data = load_csv_data(str(csv_path))
data_row = next((r for r in data if r.get("Resolution") == res), None)
if data_row:
savings = data_row.get("MemorySavingsPercent", "0")
try:
pct = float(savings)
row.append(f"{pct:.1f}%" if pct > 0 else "No change")
except ValueError:
row.append("N/A")
else:
row.append("N/A")
else:
row.append("N/A")
table_data.append(row)
columns = ["Resolution"] + scenario_names
table = ax.table(
cellText=table_data, colLabels=columns, cellLoc="center", loc="center"
)
table.auto_set_font_size(False)
table.set_fontsize(10)
table.scale(1.2, 1.5)
for i in range(len(columns)):
table[(0, i)].set_facecolor("#40466e")
table[(0, i)].set_text_props(weight="bold", color="white")
plt.title("Memory Savings Summary Table", fontsize=14, pad=20)
plt.savefig(OUTPUT_DIR / "chroma_summary_table.png", dpi=300, bbox_inches="tight")
plt.close()
def check_csv_files() -> list[str]:
"""Check which CSV files exist."""
missing = []
for name, _ in SCENARIOS:
csv_path = CSV_DIR / f"chroma_memory_{name}.csv"
if not csv_path.exists():
missing.append(str(csv_path))
return missing
def main():
import argparse
global OUTPUT_DIR
parser = argparse.ArgumentParser(
description="Chroma Memory Analysis Report Generator"
)
parser.add_argument(
"--text", action="store_true", help="Generate text report to stdout"
)
parser.add_argument("--graphs", action="store_true", help="Generate PNG graphs")
parser.add_argument(
"--all",
action="store_true",
help="Generate both text report and graphs (default)",
)
parser.add_argument(
"--output-dir",
type=str,
default=str(OUTPUT_DIR),
help=f"Output directory (default: {OUTPUT_DIR})",
)
args = parser.parse_args()
do_text = args.text or args.all or not (args.text or args.graphs)
do_graphs = args.graphs or args.all
OUTPUT_DIR = Path(args.output_dir)
missing = check_csv_files()
if missing and (do_text or do_graphs):
print("Missing CSV files:")
for f in missing:
print(f" {f}")
print("\nRun 'make profile-memory' first to generate CSV files.")
sys.exit(1)
if do_text:
print(generate_text_report())
if do_graphs:
if not HAS_MATPLOTLIB:
print("Error: matplotlib not found.")
print("Install with: pip install matplotlib numpy")
sys.exit(1)
print("\nGenerating graphs...")
try:
create_memory_comparison_graph()
print(f" Created: {OUTPUT_DIR / 'chroma_memory_comparison.png'}")
create_savings_graph()
print(f" Created: {OUTPUT_DIR / 'chroma_savings.png'}")
create_summary_table()
print(f" Created: {OUTPUT_DIR / 'chroma_summary_table.png'}")
print("\nGraph generation complete!")
except Exception as e:
print(f"Error generating graphs: {e}")
sys.exit(1)
if __name__ == "__main__":
main()

View file

@ -3,6 +3,7 @@
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <strings.h>
#include "../include/chroma.h"
@ -27,6 +28,38 @@ static char *trim_whitespace(char *str) {
}
// Remove quotes from a string
// Match output name/description against a config pattern
// Supports:
// - Exact name match: "DP-1" matches wl_output.name == "DP-1"
// - Description prefix match: "desc:Samsung" matches if description starts
// with "Samsung"
static bool match_output(const char *pattern, const char *output_name,
const char *output_description) {
if (!pattern || !output_name) {
return false;
}
// Try exact name match first
if (strcmp(pattern, output_name) == 0) {
return true;
}
// Check for description prefix match: "desc:<prefix>"
if (strncmp(pattern, "desc:", 5) == 0) {
const char *desc_prefix = pattern + 5;
size_t prefix_len = strlen(desc_prefix);
if (output_description && prefix_len > 0) {
// Match if description starts with the prefix (case-insensitive)
if (strncasecmp(output_description, desc_prefix, prefix_len) == 0) {
return true;
}
}
}
return false;
}
static char *remove_quotes(char *str) {
size_t len = strlen(str);
if (len >= 2 && ((str[0] == '"' && str[len - 1] == '"') ||
@ -196,7 +229,7 @@ static int add_output_mapping(chroma_config_t *config, const char *output_name,
return CHROMA_ERROR_MEMORY;
}
// Validate string lengths to prevent buffer overflow
// XXX: Validate string lengths to prevent buffer overflow
size_t output_len = strlen(output_name);
size_t path_len = strlen(image_path);
@ -419,9 +452,9 @@ static int parse_config_line(chroma_config_t *config, char *line,
return CHROMA_OK;
}
// Check for extended output configuration with properties
// Format: output.DP-1.scale = fill
// Format: output.DP-1.filter = linear
// Check for extended output configuration with properties. Format:
// output.DP-1.scale = fill
// output.DP-1.filter = linear
char *dot = strchr(output_name, '.');
if (dot) {
// This is an output property (scale or filter)
@ -660,16 +693,19 @@ void chroma_config_free(chroma_config_t *config) {
// Get image path for specific output
const char *chroma_config_get_image_for_output(chroma_config_t *config,
const char *output_name) {
const char *output_name,
const char *output_description) {
if (!config || !output_name) {
return NULL;
}
// Look for specific output mapping
// Look for specific output mapping (name or description match)
for (int i = 0; i < config->mapping_count; i++) {
if (strcmp(config->mappings[i].output_name, output_name) == 0) {
chroma_log("DEBUG", "Found specific mapping for output %s: %s",
output_name, config->mappings[i].image_path);
if (match_output(config->mappings[i].output_name, output_name,
output_description)) {
chroma_log("DEBUG", "Found specific mapping for output %s (desc: %s): %s",
output_name, output_description ? output_description : "none",
config->mappings[i].image_path);
return config->mappings[i].image_path;
}
}
@ -689,25 +725,28 @@ const char *chroma_config_get_image_for_output(chroma_config_t *config,
// quality, anchor, and custom anchor coordinates
int chroma_config_get_mapping_for_output(
chroma_config_t *config, const char *output_name,
chroma_scale_mode_t *scale_mode, chroma_filter_quality_t *filter_quality,
chroma_anchor_t *anchor, float *anchor_x, float *anchor_y) {
const char *output_description, chroma_scale_mode_t *scale_mode,
chroma_filter_quality_t *filter_quality, chroma_anchor_t *anchor,
float *anchor_x, float *anchor_y) {
if (!config || !output_name || !scale_mode || !filter_quality || !anchor ||
!anchor_x || !anchor_y) {
return CHROMA_ERROR_INIT;
}
// Look for specific output mapping
// Look for specific output mapping (name or description match)
for (int i = 0; i < config->mapping_count; i++) {
if (strcmp(config->mappings[i].output_name, output_name) == 0) {
if (match_output(config->mappings[i].output_name, output_name,
output_description)) {
*scale_mode = config->mappings[i].scale_mode;
*filter_quality = config->mappings[i].filter_quality;
*anchor = config->mappings[i].anchor;
*anchor_x = config->mappings[i].anchor_x;
*anchor_y = config->mappings[i].anchor_y;
chroma_log("DEBUG",
"Found specific mapping for output %s: scale=%s, filter=%s, "
"anchor=%s @ %.1f,%.1f",
output_name, scale_mode_to_string(*scale_mode),
"Found specific mapping for output %s (desc: %s): scale=%s, "
"filter=%s, anchor=%s @ %.1f,%.1f",
output_name, output_description ? output_description : "none",
scale_mode_to_string(*scale_mode),
filter_quality_to_string(*filter_quality),
anchor_to_string(*anchor), *anchor_x, *anchor_y);
return CHROMA_OK;

View file

@ -77,7 +77,8 @@ static int assign_wallpaper_to_output(chroma_state_t *state,
// Get image path for this output
const char *image_path = chroma_config_get_image_for_output(
&state->config, output->name ? output->name : "unknown");
&state->config, output->name ? output->name : "unknown",
output->description);
if (!image_path) {
chroma_log("WARN", "No wallpaper configured for output %u (%s)", output->id,
output->name ? output->name : "unknown");
@ -121,8 +122,8 @@ static int assign_wallpaper_to_output(chroma_state_t *state,
// anchor coords)
if (chroma_config_get_mapping_for_output(
&state->config, output->name ? output->name : "unknown",
&output->scale_mode, &output->filter_quality, &output->anchor,
&output->anchor_x, &output->anchor_y) == CHROMA_OK) {
output->description, &output->scale_mode, &output->filter_quality,
&output->anchor, &output->anchor_x, &output->anchor_y) == CHROMA_OK) {
output->config_loaded = true;
chroma_log("DEBUG",
"Loaded config for output %u: scale=%d, filter=%d, anchor=%d @ "

253
tests/test.c Normal file
View file

@ -0,0 +1,253 @@
#include "test_common.h"
#include <stdio.h>
#include <stdlib.h>
int test_failures = 0;
int test_total = 0;
static int test_null_image_handling(void) {
uint8_t *result = downsample_image(NULL, 100, 100, 4, NULL, NULL, 0.5f);
TEST_ASSERT(result == NULL, "Null image should return NULL");
return TEST_PASSED;
}
static int test_zero_dimensions(void) {
int dw, dh;
uint8_t *result = downsample_image(NULL, 0, 0, 4, &dw, &dh, 0.5f);
TEST_ASSERT(result == NULL, "Zero dimensions should return NULL");
return TEST_PASSED;
}
static int test_scale_one_preserves_size(void) {
uint8_t *src = create_uniform_image(100, 100, 128, 64, 255);
TEST_ASSERT_PTR_NOT_NULL(src, "Source image allocation");
int dw, dh;
uint8_t *dst = downsample_image(src, 100, 100, 4, &dw, &dh, 1.0f);
TEST_ASSERT_PTR_NOT_NULL(dst, "Scale 1.0 result");
TEST_ASSERT_EQ(dw, 100, "Width preserved at scale 1.0");
TEST_ASSERT_EQ(dh, 100, "Height preserved at scale 1.0");
int match = compare_images(src, dst, 100, 100, 4, 0.01f);
TEST_ASSERT(match == 1, "Image data preserved at scale 1.0");
free(src);
free(dst);
return TEST_PASSED;
}
static int test_downsample_half_size(void) {
uint8_t *src = create_gradient_image(100, 100);
TEST_ASSERT_PTR_NOT_NULL(src, "Gradient source");
int dw, dh;
uint8_t *dst = downsample_image(src, 100, 100, 4, &dw, &dh, 0.5f);
TEST_ASSERT_PTR_NOT_NULL(dst, "Downsample result");
TEST_ASSERT_EQ(dw, 50, "Width halved at 0.5 scale");
TEST_ASSERT_EQ(dh, 50, "Height halved at 0.5 scale");
free(src);
free(dst);
return TEST_PASSED;
}
static int test_minimum_one_pixel(void) {
uint8_t *src = create_uniform_image(1, 1, 100, 100, 100);
TEST_ASSERT_PTR_NOT_NULL(src, "Single pixel source");
int dw, dh;
uint8_t *dst = downsample_image(src, 1, 1, 4, &dw, &dh, 0.01f);
TEST_ASSERT_PTR_NOT_NULL(dst, "Minimum size result");
TEST_ASSERT_EQ(dw, 1, "Width minimum 1 pixel");
TEST_ASSERT_EQ(dh, 1, "Height minimum 1 pixel");
free(src);
free(dst);
return TEST_PASSED;
}
static int test_large_image_handling(void) {
uint8_t *src = create_noise_image(4096, 4096, 42);
TEST_ASSERT_PTR_NOT_NULL(src, "Large image allocation");
int dw, dh;
uint8_t *dst = downsample_image(src, 4096, 4096, 4, &dw, &dh, 0.25f);
TEST_ASSERT_PTR_NOT_NULL(dst, "Large image downsample");
TEST_ASSERT_EQ(dw, 1024, "Correct width at 0.25 scale");
TEST_ASSERT_EQ(dh, 1024, "Correct height at 0.25 scale");
free(src);
free(dst);
return TEST_PASSED;
}
static int test_alpha_channel_preserved(void) {
uint8_t *src = create_uniform_image(50, 50, 255, 0, 0);
TEST_ASSERT_PTR_NOT_NULL(src, "Red source");
int dw, dh;
uint8_t *dst = downsample_image(src, 50, 50, 4, &dw, &dh, 0.5f);
TEST_ASSERT_PTR_NOT_NULL(dst, "Downsample with alpha");
TEST_ASSERT_EQ(dw, 25, "Width halved");
TEST_ASSERT_EQ(dh, 25, "Height halved");
TEST_ASSERT(dst[0 * 4 + 3] == 255, "Alpha channel preserved");
free(src);
free(dst);
return TEST_PASSED;
}
static int test_uniform_color_accuracy(void) {
uint8_t *src = create_uniform_image(100, 100, 200, 100, 50);
TEST_ASSERT_PTR_NOT_NULL(src, "Uniform color source");
int dw, dh;
uint8_t *dst = downsample_image(src, 100, 100, 4, &dw, &dh, 0.25f);
TEST_ASSERT_PTR_NOT_NULL(dst, "Uniform color downsample");
int correct = 1;
for (int i = 0; i < dw * dh; i++) {
if (dst[i * 4 + 0] != 200 || dst[i * 4 + 1] != 100 || dst[i * 4 + 2] != 50) {
correct = 0;
break;
}
}
TEST_ASSERT(correct == 1, "Uniform color preserved in downsampling");
free(src);
free(dst);
return TEST_PASSED;
}
static int test_gradient_smoothness(void) {
uint8_t *src = create_gradient_image(100, 100);
TEST_ASSERT_PTR_NOT_NULL(src, "Gradient source");
int dw, dh;
uint8_t *dst = downsample_image(src, 100, 100, 4, &dw, &dh, 0.5f);
TEST_ASSERT_PTR_NOT_NULL(dst, "Gradient downsample");
float psnr = compute_psnr(src, dst, dw, dh, 4);
TEST_ASSERT_FTZ(psnr, 9.0f, 5.0f, "Gradient PSNR within expected range");
free(src);
free(dst);
return TEST_PASSED;
}
static int test_checkerboard_no_aliasing(void) {
uint8_t *src = create_checkerboard(100, 100, 10);
TEST_ASSERT_PTR_NOT_NULL(src, "Checkerboard source");
int dw, dh;
uint8_t *dst = downsample_image(src, 100, 100, 4, &dw, &dh, 0.5f);
TEST_ASSERT_PTR_NOT_NULL(dst, "Checkerboard downsample");
TEST_ASSERT_EQ(dw, 50, "Width halved");
TEST_ASSERT_EQ(dh, 50, "Height halved");
free(src);
free(dst);
return TEST_PASSED;
}
static TestCase tests[] = {
{"null_image_handling", test_null_image_handling},
{"zero_dimensions", test_zero_dimensions},
{"scale_one_preserves_size", test_scale_one_preserves_size},
{"downsample_half_size", test_downsample_half_size},
{"minimum_one_pixel", test_minimum_one_pixel},
{"large_image_handling", test_large_image_handling},
{"alpha_channel_preserved", test_alpha_channel_preserved},
{"uniform_color_accuracy", test_uniform_color_accuracy},
{"gradient_smoothness", test_gradient_smoothness},
{"checkerboard_no_aliasing", test_checkerboard_no_aliasing},
};
int main(int argc, char **argv) {
int profile = 0;
for (int i = 1; i < argc; i++) {
if (strcmp(argv[i], "--profile") == 0) {
profile = 1;
}
}
if (profile) {
const char *scenarios[] = {"No_Downsampling", "1080p_Target", "1440p_Target", "4K_Target"};
float scales[] = {1.0f, 0.5f, 0.42f, 0.25f};
for (int s = 0; s < 4; s++) {
char filename[256];
snprintf(filename, sizeof(filename), "/tmp/chroma_memory_%s.csv", scenarios[s]);
FILE *f = fopen(filename, "w");
if (!f) {
fprintf(stderr, "Failed to create %s\n", filename);
continue;
}
fprintf(f, "Resolution,OriginalSizeMB,DownsampledSizeMB,MemorySavingsPercent\n");
const char *res_names[] = {"1080p", "1440p", "4K", "5K", "6K", "8K"};
int widths[] = {1920, 2560, 3840, 5120, 6016, 7680};
int heights[] = {1080, 1440, 2160, 2880, 3200, 4320};
for (int r = 0; r < 6; r++) {
int w = widths[r];
int h = heights[r];
size_t original_bytes = (size_t)w * h * 4;
double original_mb = original_bytes / (1024.0 * 1024.0);
int dw, dh;
uint8_t *src = create_noise_image(w, h, 42);
uint8_t *dst = downsample_image(src, w, h, 4, &dw, &dh, scales[s]);
size_t downsampled_bytes = (size_t)dw * dh * 4;
double downsampled_mb = downsampled_bytes / (1024.0 * 1024.0);
double savings = 0.0;
if (original_mb > 0) {
savings = ((original_mb - downsampled_mb) / original_mb) * 100.0;
}
fprintf(f, "%s,%.2f,%.2f,%.1f\n", res_names[r], original_mb, downsampled_mb, savings);
free(src);
free(dst);
}
fclose(f);
printf("Generated: %s\n", filename);
}
return 0;
}
(void)argc;
(void)argv;
printf("Chroma Unit Tests\n");
printf("=================\n\n");
test_failures = 0;
test_total = 0;
for (int i = 0; i < (int)(sizeof(tests) / sizeof(tests[0])); i++) {
int result = tests[i].fn();
test_total++;
if (result == TEST_PASSED) {
printf(" [PASS] %s\n", tests[i].name);
} else {
printf(" [FAIL] %s\n", tests[i].name);
}
}
printf("\n-----------------\n");
printf("Results: %d/%d passed\n", test_total - test_failures, test_total);
return test_failures > 0 ? 1 : 0;
}