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. Prototype Flutter app for generating bottle-cap mosaics from imported images.
## Implemented MVP ## Features
- Import target image from gallery (`image_picker`) - Import target image from gallery (`image_picker`)
- Resolution controls: - Resolution controls:
@@ -12,46 +12,50 @@ Prototype Flutter app for generating bottle-cap mosaics from imported images.
- list caps with name + color - list caps with name + color
- add color via picker and/or manual hex - add color via picker and/or manual hex
- remove caps - remove caps
- Mosaic generation: - Mosaic preview + bill of materials counts per cap color
- 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
## 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` - **Fidelity ↔ Structure** slider
- `flutter pub get` - fidelity side keeps direct color faithfulness
- `flutter analyze` - structure side applies edge-aware smoothing for cleaner large forms
- `flutter build apk --debug` - **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 ```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 cd /home/yadciel/.openclaw/workspace/korken_mosaic
flutter create .
flutter pub get flutter pub get
flutter run flutter build apk --debug --split-per-abi
flutter build apk --debug
``` ```
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 - `lib/main.dart` UI + mosaic logic
- `pubspec.yaml` dependencies - `README.md` overview and build instructions
- `analysis_options.yaml`
- `.gitignore`

View File

@@ -25,6 +25,8 @@ class KorkenMosaicApp extends StatelessWidget {
} }
} }
enum StylePreset { realistisch, ausgewogen, kuenstlerisch }
class MosaicHomePage extends StatefulWidget { class MosaicHomePage extends StatefulWidget {
const MosaicHomePage({super.key}); const MosaicHomePage({super.key});
@@ -47,6 +49,12 @@ class _MosaicHomePageState extends State<MosaicHomePage> {
Timer? _debounceTimer; Timer? _debounceTimer;
int _generationToken = 0; 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 = [ final List<CapColor> _palette = [
CapColor(name: 'White', color: const Color(0xFFF2F2F2)), CapColor(name: 'White', color: const Color(0xFFF2F2F2)),
CapColor(name: 'Black', color: const Color(0xFF222222)), CapColor(name: 'Black', color: const Color(0xFF222222)),
@@ -85,7 +93,41 @@ class _MosaicHomePageState extends State<MosaicHomePage> {
void _scheduleRegenerate() { void _scheduleRegenerate() {
if (_sourceImageBytes == null || _result == null) return; if (_sourceImageBytes == null || _result == null) return;
_debounceTimer?.cancel(); _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() { void _addCapDialog() {
@@ -172,6 +214,10 @@ class _MosaicHomePageState extends State<MosaicHomePage> {
'gridW': gridW, 'gridW': gridW,
'gridH': gridH, 'gridH': gridH,
'capSize': capSize, 'capSize': capSize,
'fidelityStructure': _fidelityStructure,
'ditheringStrength': _ditheringStrength,
'edgeEmphasis': _edgeEmphasis,
'colorVariation': _colorVariation,
'palette': _palette 'palette': _palette
.map((p) => .map((p) =>
<String, dynamic>{'name': p.name, 'value': p.color.toARGB32()}) <String, dynamic>{'name': p.name, 'value': p.color.toARGB32()})
@@ -287,6 +333,81 @@ class _MosaicHomePageState extends State<MosaicHomePage> {
), ),
), ),
const SizedBox(height: 16), 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( Row(
children: [ children: [
const Expanded( 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 { class CapColor {
final String name; final String name;
final Color color; final Color color;
@@ -399,6 +557,10 @@ Map<String, dynamic> _generateMosaicIsolate(Map<String, dynamic> request) {
final defaultGridW = request['gridW'] as int; final defaultGridW = request['gridW'] as int;
final defaultGridH = request['gridH'] as int; final defaultGridH = request['gridH'] as int;
final capSize = request['capSize'] 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 paletteRaw = (request['palette'] as List).cast<Map>();
final decoded = img.decodeImage(source); final decoded = img.decodeImage(source);
@@ -422,49 +584,188 @@ Map<String, dynamic> _generateMosaicIsolate(Map<String, dynamic> request) {
? math.max(1, (decoded.height / capSize).round()) ? math.max(1, (decoded.height / capSize).round())
: defaultGridH; : defaultGridH;
final interpolation = fidelityStructure < 0.4
? img.Interpolation.average
: img.Interpolation.linear;
final scaled = img.copyResize( final scaled = img.copyResize(
decoded, decoded,
width: gridW, width: gridW,
height: gridH, 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 paletteValues = paletteRaw.map((e) => e['value'] as int).toList();
final paletteLab = paletteValues.map(_argbToLab).toList(growable: false); 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 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>>{}; final labCache = <int, List<double>>{};
for (int y = 0; y < gridH; y++) { for (int y = 0; y < gridH; y++) {
for (int x = 0; x < gridW; x++) { for (int x = 0; x < gridW; x++) {
final pix = scaled.getPixel(x, y); final idx = y * gridW + x;
final rgb = (pix.r.toInt() << 16) | (pix.g.toInt() << 8) | pix.b.toInt();
final cached = nearestCache[rgb]; final srcR = (workingR[idx] + errorR[idx]).clamp(0.0, 255.0);
if (cached != null) { final srcG = (workingG[idx] + errorG[idx]).clamp(0.0, 255.0);
assignments[y * gridW + x] = cached; final srcB = (workingB[idx] + errorB[idx]).clamp(0.0, 255.0);
counts[cached]++;
continue;
}
final rgb = ((srcR.round() & 0xFF) << 16) |
((srcG.round() & 0xFF) << 8) |
(srcB.round() & 0xFF);
final srcLab = labCache.putIfAbsent(rgb, () => _rgbToLab(rgb)); final srcLab = labCache.putIfAbsent(rgb, () => _rgbToLab(rgb));
int bestIdx = 0; int bestIdx = 0;
double bestDistance = double.infinity; 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++) { 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) { if (d < bestDistance) {
bestDistance = d; bestDistance = d;
bestIdx = i; bestIdx = i;
} }
} }
nearestCache[rgb] = bestIdx; for (int i = 0; i < paletteLab.length; i++) {
assignments[y * gridW + x] = bestIdx; final lDiff = (srcLab[0] - paletteLab[i][0]).abs();
counts[bestIdx]++; 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) { List<double> _argbToLab(int argb) {
final rgb = argb & 0x00FFFFFF; final rgb = argb & 0x00FFFFFF;
return _rgbToLab(rgb); return _rgbToLab(rgb);