From 4cbd4eb478eaf60ffcfd6aa35d9f49245a66afe4 Mon Sep 17 00:00:00 2001 From: gary Date: Sat, 21 Feb 2026 20:28:04 +0100 Subject: [PATCH] Add style controls, presets, and edge-aware variation quantization --- README.md | 66 +++++----- lib/main.dart | 342 +++++++++++++++++++++++++++++++++++++++++++++++--- 2 files changed, 361 insertions(+), 47 deletions(-) diff --git a/README.md b/README.md index c504376..49c1705 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ Prototype Flutter app for generating bottle-cap mosaics from imported images. -## Implemented MVP +## Features - Import target image from gallery (`image_picker`) - Resolution controls: @@ -12,46 +12,50 @@ Prototype Flutter app for generating bottle-cap mosaics from imported images. - list caps with name + color - add color via picker and/or manual hex - remove caps -- Mosaic generation: - - resize source to grid - - nearest cap color match using CIELAB + DeltaE (CIE76) - - fallback concept is RGB distance, but LAB path is implemented directly -- Output: - - mosaic grid preview - - bill of materials counts per cap color +- Mosaic preview + bill of materials counts per cap color -## Current blocker on this machine +## Style controls (new) -`flutter` SDK is not installed (`flutter: command not found`), so I could not run: +User-facing controls are integrated directly in the main screen: -- `flutter create` -- `flutter pub get` -- `flutter analyze` -- `flutter build apk --debug` +- **Fidelity ↔ Structure** slider + - fidelity side keeps direct color faithfulness + - structure side applies edge-aware smoothing for cleaner large forms +- **Dithering strength** slider + - scales Floyd–Steinberg error diffusion +- **Edge emphasis** slider + - boosts edge readability during color assignment +- **Color tolerance / variation** slider + - allows controlled variation among similar cap colors in flatter regions -## Setup commands (Ubuntu/Debian) +## Presets + +Three presets are provided and selectable via segmented buttons: + +- **Realistisch** +- **Ausgewogen** +- **Künstlerisch** + +Selecting a preset sets all four style controls at once. + +## Pipeline notes + +The generation pipeline still uses fast CIELAB (`DeltaE CIE76`) nearest-color matching, +with precomputed palette LAB values, LAB caching, and low-cost edge-aware processing to keep performance responsive. + +## Build (arm64 split debug) ```bash -cd /home/yadciel/.openclaw/workspace -sudo snap install flutter --classic -# OR: install manually from flutter.dev and add to PATH - -flutter doctor - cd /home/yadciel/.openclaw/workspace/korken_mosaic -flutter create . flutter pub get -flutter run -flutter build apk --debug +flutter build apk --debug --split-per-abi ``` -Expected APK artifact: +Expected arm64 artifact: -`build/app/outputs/flutter-apk/app-debug.apk` +`build/app/outputs/flutter-apk/app-arm64-v8a-debug.apk` -## Project files +## Main files -- `lib/main.dart` – complete MVP UI + mosaic logic -- `pubspec.yaml` – dependencies -- `analysis_options.yaml` -- `.gitignore` +- `lib/main.dart` – UI + mosaic logic +- `README.md` – overview and build instructions diff --git a/lib/main.dart b/lib/main.dart index 553fcc2..9114bf7 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -25,6 +25,8 @@ class KorkenMosaicApp extends StatelessWidget { } } +enum StylePreset { realistisch, ausgewogen, kuenstlerisch } + class MosaicHomePage extends StatefulWidget { const MosaicHomePage({super.key}); @@ -47,6 +49,12 @@ class _MosaicHomePageState extends State { Timer? _debounceTimer; int _generationToken = 0; + double _fidelityStructure = 0.5; + double _ditheringStrength = 0.35; + double _edgeEmphasis = 0.4; + double _colorVariation = 0.3; + StylePreset _selectedPreset = StylePreset.ausgewogen; + final List _palette = [ CapColor(name: 'White', color: const Color(0xFFF2F2F2)), CapColor(name: 'Black', color: const Color(0xFF222222)), @@ -85,7 +93,41 @@ class _MosaicHomePageState extends State { void _scheduleRegenerate() { if (_sourceImageBytes == null || _result == null) return; _debounceTimer?.cancel(); - _debounceTimer = Timer(const Duration(milliseconds: 300), _generate); + _debounceTimer = Timer(const Duration(milliseconds: 250), _generate); + } + + void _applyPreset(StylePreset preset) { + setState(() { + _selectedPreset = preset; + switch (preset) { + case StylePreset.realistisch: + _fidelityStructure = 0.2; + _ditheringStrength = 0.15; + _edgeEmphasis = 0.25; + _colorVariation = 0.1; + break; + case StylePreset.ausgewogen: + _fidelityStructure = 0.5; + _ditheringStrength = 0.35; + _edgeEmphasis = 0.4; + _colorVariation = 0.3; + break; + case StylePreset.kuenstlerisch: + _fidelityStructure = 0.82; + _ditheringStrength = 0.75; + _edgeEmphasis = 0.75; + _colorVariation = 0.78; + break; + } + }); + _scheduleRegenerate(); + } + + void _onStyleChanged() { + if (_selectedPreset != StylePreset.ausgewogen) { + setState(() => _selectedPreset = StylePreset.ausgewogen); + } + _scheduleRegenerate(); } void _addCapDialog() { @@ -172,6 +214,10 @@ class _MosaicHomePageState extends State { 'gridW': gridW, 'gridH': gridH, 'capSize': capSize, + 'fidelityStructure': _fidelityStructure, + 'ditheringStrength': _ditheringStrength, + 'edgeEmphasis': _edgeEmphasis, + 'colorVariation': _colorVariation, 'palette': _palette .map((p) => {'name': p.name, 'value': p.color.toARGB32()}) @@ -287,6 +333,81 @@ class _MosaicHomePageState extends State { ), ), const SizedBox(height: 16), + const Text( + 'Style Preset', + style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold), + ), + const SizedBox(height: 8), + SegmentedButton( + segments: const [ + ButtonSegment( + value: StylePreset.realistisch, + label: Text('Realistisch'), + ), + ButtonSegment( + value: StylePreset.ausgewogen, + label: Text('Ausgewogen'), + ), + ButtonSegment( + value: StylePreset.kuenstlerisch, + label: Text('Künstlerisch'), + ), + ], + selected: {_selectedPreset}, + onSelectionChanged: (s) => _applyPreset(s.first), + ), + const SizedBox(height: 8), + Card( + child: Padding( + padding: + const EdgeInsets.symmetric(horizontal: 12, vertical: 8), + child: Column( + children: [ + _SliderRow( + label: 'Fidelity ↔ Structure', + leftLabel: 'Fidelity', + rightLabel: 'Structure', + value: _fidelityStructure, + onChanged: (v) { + setState(() => _fidelityStructure = v); + _onStyleChanged(); + }, + ), + _SliderRow( + label: 'Dithering strength', + leftLabel: 'Off', + rightLabel: 'Strong', + value: _ditheringStrength, + onChanged: (v) { + setState(() => _ditheringStrength = v); + _onStyleChanged(); + }, + ), + _SliderRow( + label: 'Edge emphasis', + leftLabel: 'Soft', + rightLabel: 'Crisp', + value: _edgeEmphasis, + onChanged: (v) { + setState(() => _edgeEmphasis = v); + _onStyleChanged(); + }, + ), + _SliderRow( + label: 'Color tolerance / variation', + leftLabel: 'Strict', + rightLabel: 'Varied', + value: _colorVariation, + onChanged: (v) { + setState(() => _colorVariation = v); + _onStyleChanged(); + }, + ), + ], + ), + ), + ), + const SizedBox(height: 16), Row( children: [ const Expanded( @@ -366,6 +487,43 @@ class _MosaicHomePageState extends State { } } +class _SliderRow extends StatelessWidget { + final String label; + final String leftLabel; + final String rightLabel; + final double value; + final ValueChanged onChanged; + + const _SliderRow({ + required this.label, + required this.leftLabel, + required this.rightLabel, + required this.value, + required this.onChanged, + }); + + @override + Widget build(BuildContext context) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(label, style: const TextStyle(fontWeight: FontWeight.w600)), + Slider(value: value, onChanged: onChanged), + Padding( + padding: const EdgeInsets.only(bottom: 8), + child: Row( + children: [ + Text(leftLabel, style: Theme.of(context).textTheme.bodySmall), + const Spacer(), + Text(rightLabel, style: Theme.of(context).textTheme.bodySmall), + ], + ), + ), + ], + ); + } +} + class CapColor { final String name; final Color color; @@ -399,6 +557,10 @@ Map _generateMosaicIsolate(Map request) { final defaultGridW = request['gridW'] as int; final defaultGridH = request['gridH'] as int; final capSize = request['capSize'] as int; + final fidelityStructure = (request['fidelityStructure'] as num).toDouble(); + final ditheringStrength = (request['ditheringStrength'] as num).toDouble(); + final edgeEmphasis = (request['edgeEmphasis'] as num).toDouble(); + final colorVariation = (request['colorVariation'] as num).toDouble(); final paletteRaw = (request['palette'] as List).cast(); final decoded = img.decodeImage(source); @@ -422,49 +584,188 @@ Map _generateMosaicIsolate(Map request) { ? math.max(1, (decoded.height / capSize).round()) : defaultGridH; + final interpolation = fidelityStructure < 0.4 + ? img.Interpolation.average + : img.Interpolation.linear; final scaled = img.copyResize( decoded, width: gridW, height: gridH, - interpolation: img.Interpolation.average, + interpolation: interpolation, ); + final pixelCount = gridW * gridH; + final workingR = List.filled(pixelCount, 0); + final workingG = List.filled(pixelCount, 0); + final workingB = List.filled(pixelCount, 0); + final luminance = List.filled(pixelCount, 0); + + for (int y = 0; y < gridH; y++) { + for (int x = 0; x < gridW; x++) { + final idx = y * gridW + x; + final pix = scaled.getPixel(x, y); + final r = pix.r.toDouble(); + final g = pix.g.toDouble(); + final b = pix.b.toDouble(); + workingR[idx] = r; + workingG[idx] = g; + workingB[idx] = b; + luminance[idx] = 0.2126 * r + 0.7152 * g + 0.0722 * b; + } + } + + final edgeStrength = List.filled(pixelCount, 0); + for (int y = 0; y < gridH; y++) { + final ym = y > 0 ? y - 1 : y; + final yp = y + 1 < gridH ? y + 1 : y; + for (int x = 0; x < gridW; x++) { + final xm = x > 0 ? x - 1 : x; + final xp = x + 1 < gridW ? x + 1 : x; + final left = luminance[y * gridW + xm]; + final right = luminance[y * gridW + xp]; + final up = luminance[ym * gridW + x]; + final down = luminance[yp * gridW + x]; + final gx = right - left; + final gy = down - up; + edgeStrength[y * gridW + x] = + (math.sqrt(gx * gx + gy * gy) / 255.0).clamp(0.0, 1.0); + } + } + + if (fidelityStructure > 0.001) { + final blurredR = List.filled(pixelCount, 0); + final blurredG = List.filled(pixelCount, 0); + final blurredB = List.filled(pixelCount, 0); + for (int y = 0; y < gridH; y++) { + for (int x = 0; x < gridW; x++) { + double sr = 0, sg = 0, sb = 0; + int c = 0; + for (int ny = math.max(0, y - 1); + ny <= math.min(gridH - 1, y + 1); + ny++) { + for (int nx = math.max(0, x - 1); + nx <= math.min(gridW - 1, x + 1); + nx++) { + final ni = ny * gridW + nx; + sr += workingR[ni]; + sg += workingG[ni]; + sb += workingB[ni]; + c++; + } + } + final idx = y * gridW + x; + blurredR[idx] = sr / c; + blurredG[idx] = sg / c; + blurredB[idx] = sb / c; + } + } + + for (int i = 0; i < pixelCount; i++) { + final smoothBlend = fidelityStructure * (1.0 - edgeStrength[i] * 0.75); + workingR[i] = _mix(workingR[i], blurredR[i], smoothBlend); + workingG[i] = _mix(workingG[i], blurredG[i], smoothBlend); + workingB[i] = _mix(workingB[i], blurredB[i], smoothBlend); + } + } + final paletteValues = paletteRaw.map((e) => e['value'] as int).toList(); final paletteLab = paletteValues.map(_argbToLab).toList(growable: false); - final assignments = List.filled(gridW * gridH, 0); + final assignments = List.filled(pixelCount, 0); final counts = List.filled(paletteValues.length, 0); - final nearestCache = {}; + final errorR = List.filled(pixelCount, 0); + final errorG = List.filled(pixelCount, 0); + final errorB = List.filled(pixelCount, 0); final labCache = >{}; for (int y = 0; y < gridH; y++) { for (int x = 0; x < gridW; x++) { - final pix = scaled.getPixel(x, y); - final rgb = (pix.r.toInt() << 16) | (pix.g.toInt() << 8) | pix.b.toInt(); + final idx = y * gridW + x; - final cached = nearestCache[rgb]; - if (cached != null) { - assignments[y * gridW + x] = cached; - counts[cached]++; - continue; - } + final srcR = (workingR[idx] + errorR[idx]).clamp(0.0, 255.0); + final srcG = (workingG[idx] + errorG[idx]).clamp(0.0, 255.0); + final srcB = (workingB[idx] + errorB[idx]).clamp(0.0, 255.0); + final rgb = ((srcR.round() & 0xFF) << 16) | + ((srcG.round() & 0xFF) << 8) | + (srcB.round() & 0xFF); final srcLab = labCache.putIfAbsent(rgb, () => _rgbToLab(rgb)); int bestIdx = 0; double bestDistance = double.infinity; + final candidates = <_Candidate>[]; + final localVar = + colorVariation * (1.0 - edgeStrength[idx]).clamp(0.0, 1.0); + final tolerance = 2.0 + localVar * 22.0; + for (int i = 0; i < paletteLab.length; i++) { - final d = _deltaE76(srcLab, paletteLab[i]); + final lDiff = (srcLab[0] - paletteLab[i][0]).abs(); + final d = _deltaE76(srcLab, paletteLab[i]) + + (edgeEmphasis * edgeStrength[idx] * lDiff * 0.35); if (d < bestDistance) { bestDistance = d; bestIdx = i; } } - nearestCache[rgb] = bestIdx; - assignments[y * gridW + x] = bestIdx; - counts[bestIdx]++; + for (int i = 0; i < paletteLab.length; i++) { + final lDiff = (srcLab[0] - paletteLab[i][0]).abs(); + final d = _deltaE76(srcLab, paletteLab[i]) + + (edgeEmphasis * edgeStrength[idx] * lDiff * 0.35); + if (d <= bestDistance + tolerance) { + candidates.add(_Candidate(index: i, distance: d)); + } + } + + int chosen = bestIdx; + if (localVar > 0.001 && candidates.length > 1) { + candidates.sort((a, b) => a.distance.compareTo(b.distance)); + final usable = math.min(candidates.length, 4); + final h = ((x + 1) * 73856093) ^ ((y + 1) * 19349663); + final pick = h.abs() % usable; + chosen = candidates[pick].index; + } + + assignments[idx] = chosen; + counts[chosen]++; + + if (ditheringStrength > 0.001) { + final argb = paletteValues[chosen]; + final qr = ((argb >> 16) & 0xFF).toDouble(); + final qg = ((argb >> 8) & 0xFF).toDouble(); + final qb = (argb & 0xFF).toDouble(); + + final er = (srcR - qr) * ditheringStrength; + final eg = (srcG - qg) * ditheringStrength; + final eb = (srcB - qb) * ditheringStrength; + + if (x + 1 < gridW) { + final n = idx + 1; + errorR[n] += er * (7 / 16); + errorG[n] += eg * (7 / 16); + errorB[n] += eb * (7 / 16); + } + if (y + 1 < gridH) { + if (x > 0) { + final n = idx + gridW - 1; + errorR[n] += er * (3 / 16); + errorG[n] += eg * (3 / 16); + errorB[n] += eb * (3 / 16); + } + final nDown = idx + gridW; + errorR[nDown] += er * (5 / 16); + errorG[nDown] += eg * (5 / 16); + errorB[nDown] += eb * (5 / 16); + + if (x + 1 < gridW) { + final n = idx + gridW + 1; + errorR[n] += er * (1 / 16); + errorG[n] += eg * (1 / 16); + errorB[n] += eb * (1 / 16); + } + } + } } } @@ -494,6 +795,15 @@ Map _generateMosaicIsolate(Map request) { }; } +class _Candidate { + final int index; + final double distance; + + const _Candidate({required this.index, required this.distance}); +} + +double _mix(double a, double b, double t) => a + (b - a) * t; + List _argbToLab(int argb) { final rgb = argb & 0x00FFFFFF; return _rgbToLab(rgb);