Add style controls, presets, and edge-aware variation quantization

This commit is contained in:
gary
2026-02-21 20:28:04 +01:00
parent a00d456d03
commit 4cbd4eb478
2 changed files with 361 additions and 47 deletions

View File

@@ -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 FloydSteinberg 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

View File

@@ -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<MosaicHomePage> {
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<CapColor> _palette = [
CapColor(name: 'White', color: const Color(0xFFF2F2F2)),
CapColor(name: 'Black', color: const Color(0xFF222222)),
@@ -85,7 +93,41 @@ class _MosaicHomePageState extends State<MosaicHomePage> {
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<MosaicHomePage> {
'gridW': gridW,
'gridH': gridH,
'capSize': capSize,
'fidelityStructure': _fidelityStructure,
'ditheringStrength': _ditheringStrength,
'edgeEmphasis': _edgeEmphasis,
'colorVariation': _colorVariation,
'palette': _palette
.map((p) =>
<String, dynamic>{'name': p.name, 'value': p.color.toARGB32()})
@@ -287,6 +333,81 @@ class _MosaicHomePageState extends State<MosaicHomePage> {
),
),
const SizedBox(height: 16),
const Text(
'Style Preset',
style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
),
const SizedBox(height: 8),
SegmentedButton<StylePreset>(
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<MosaicHomePage> {
}
}
class _SliderRow extends StatelessWidget {
final String label;
final String leftLabel;
final String rightLabel;
final double value;
final ValueChanged<double> 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<String, dynamic> _generateMosaicIsolate(Map<String, dynamic> 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<Map>();
final decoded = img.decodeImage(source);
@@ -422,49 +584,188 @@ Map<String, dynamic> _generateMosaicIsolate(Map<String, dynamic> 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<double>.filled(pixelCount, 0);
final workingG = List<double>.filled(pixelCount, 0);
final workingB = List<double>.filled(pixelCount, 0);
final luminance = List<double>.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<double>.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<double>.filled(pixelCount, 0);
final blurredG = List<double>.filled(pixelCount, 0);
final blurredB = List<double>.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<int>.filled(gridW * gridH, 0);
final assignments = List<int>.filled(pixelCount, 0);
final counts = List<int>.filled(paletteValues.length, 0);
final nearestCache = <int, int>{};
final errorR = List<double>.filled(pixelCount, 0);
final errorG = List<double>.filled(pixelCount, 0);
final errorB = List<double>.filled(pixelCount, 0);
final labCache = <int, List<double>>{};
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<String, dynamic> _generateMosaicIsolate(Map<String, dynamic> 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<double> _argbToLab(int argb) {
final rgb = argb & 0x00FFFFFF;
return _rgbToLab(rgb);