Add style controls, presets, and edge-aware variation quantization
This commit is contained in:
342
lib/main.dart
342
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<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);
|
||||
|
||||
Reference in New Issue
Block a user