Compare commits
2 Commits
d65875ba84
...
4cbd4eb478
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4cbd4eb478 | ||
|
|
a00d456d03 |
66
README.md
66
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
|
||||
|
||||
650
lib/main.dart
650
lib/main.dart
@@ -1,6 +1,8 @@
|
||||
import 'dart:async';
|
||||
import 'dart:math' as math;
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_colorpicker/flutter_colorpicker.dart';
|
||||
import 'package:image/image.dart' as img;
|
||||
@@ -23,6 +25,8 @@ class KorkenMosaicApp extends StatelessWidget {
|
||||
}
|
||||
}
|
||||
|
||||
enum StylePreset { realistisch, ausgewogen, kuenstlerisch }
|
||||
|
||||
class MosaicHomePage extends StatefulWidget {
|
||||
const MosaicHomePage({super.key});
|
||||
|
||||
@@ -32,13 +36,24 @@ class MosaicHomePage extends StatefulWidget {
|
||||
|
||||
class _MosaicHomePageState extends State<MosaicHomePage> {
|
||||
final ImagePicker _picker = ImagePicker();
|
||||
final TextEditingController _gridWidthCtrl = TextEditingController(text: '40');
|
||||
final TextEditingController _gridHeightCtrl = TextEditingController(text: '30');
|
||||
final TextEditingController _gridWidthCtrl =
|
||||
TextEditingController(text: '40');
|
||||
final TextEditingController _gridHeightCtrl =
|
||||
TextEditingController(text: '30');
|
||||
final TextEditingController _capSizeCtrl = TextEditingController(text: '12');
|
||||
|
||||
Uint8List? _sourceImageBytes;
|
||||
MosaicResult? _result;
|
||||
bool _useCapSize = false;
|
||||
bool _isGenerating = false;
|
||||
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)),
|
||||
@@ -48,8 +63,17 @@ class _MosaicHomePageState extends State<MosaicHomePage> {
|
||||
CapColor(name: 'Green', color: const Color(0xFF4FAE63)),
|
||||
];
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_gridWidthCtrl.addListener(_scheduleRegenerate);
|
||||
_gridHeightCtrl.addListener(_scheduleRegenerate);
|
||||
_capSizeCtrl.addListener(_scheduleRegenerate);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_debounceTimer?.cancel();
|
||||
_gridWidthCtrl.dispose();
|
||||
_gridHeightCtrl.dispose();
|
||||
_capSizeCtrl.dispose();
|
||||
@@ -66,10 +90,53 @@ class _MosaicHomePageState extends State<MosaicHomePage> {
|
||||
});
|
||||
}
|
||||
|
||||
void _scheduleRegenerate() {
|
||||
if (_sourceImageBytes == null || _result == null) return;
|
||||
_debounceTimer?.cancel();
|
||||
_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() {
|
||||
Color selected = Colors.orange;
|
||||
final nameCtrl = TextEditingController();
|
||||
final hexCtrl = TextEditingController(text: '#${selected.toARGB32().toRadixString(16).substring(2).toUpperCase()}');
|
||||
final hexCtrl = TextEditingController(
|
||||
text:
|
||||
'#${selected.toARGB32().toRadixString(16).substring(2).toUpperCase()}',
|
||||
);
|
||||
|
||||
showDialog<void>(
|
||||
context: context,
|
||||
@@ -98,22 +165,30 @@ class _MosaicHomePageState extends State<MosaicHomePage> {
|
||||
pickerColor: selected,
|
||||
onColorChanged: (c) {
|
||||
selected = c;
|
||||
hexCtrl.text = '#${c.toARGB32().toRadixString(16).substring(2).toUpperCase()}';
|
||||
hexCtrl.text =
|
||||
'#${c.toARGB32().toRadixString(16).substring(2).toUpperCase()}';
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
actions: [
|
||||
TextButton(onPressed: () => Navigator.pop(ctx), child: const Text('Cancel')),
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(ctx),
|
||||
child: const Text('Cancel'),
|
||||
),
|
||||
FilledButton(
|
||||
onPressed: () {
|
||||
final name = nameCtrl.text.trim();
|
||||
if (name.isEmpty) return;
|
||||
setState(() {
|
||||
_palette.add(CapColor(name: name, color: _parseHex(hexCtrl.text) ?? selected));
|
||||
_palette.add(
|
||||
CapColor(
|
||||
name: name, color: _parseHex(hexCtrl.text) ?? selected),
|
||||
);
|
||||
});
|
||||
Navigator.pop(ctx);
|
||||
_scheduleRegenerate();
|
||||
},
|
||||
child: const Text('Add'),
|
||||
),
|
||||
@@ -123,52 +198,62 @@ class _MosaicHomePageState extends State<MosaicHomePage> {
|
||||
);
|
||||
}
|
||||
|
||||
void _generate() {
|
||||
Future<void> _generate() async {
|
||||
if (_sourceImageBytes == null || _palette.isEmpty) return;
|
||||
final decoded = img.decodeImage(_sourceImageBytes!);
|
||||
if (decoded == null) return;
|
||||
|
||||
final int gridW;
|
||||
final int gridH;
|
||||
if (_useCapSize) {
|
||||
final capSize = math.max(1, int.tryParse(_capSizeCtrl.text) ?? 12);
|
||||
gridW = math.max(1, (decoded.width / capSize).round());
|
||||
gridH = math.max(1, (decoded.height / capSize).round());
|
||||
} else {
|
||||
gridW = math.max(1, int.tryParse(_gridWidthCtrl.text) ?? 40);
|
||||
gridH = math.max(1, int.tryParse(_gridHeightCtrl.text) ?? 30);
|
||||
}
|
||||
final int gridW = math.max(1, int.tryParse(_gridWidthCtrl.text) ?? 40);
|
||||
final int gridH = math.max(1, int.tryParse(_gridHeightCtrl.text) ?? 30);
|
||||
final int capSize = math.max(1, int.tryParse(_capSizeCtrl.text) ?? 12);
|
||||
|
||||
final scaled = img.copyResize(decoded, width: gridW, height: gridH, interpolation: img.Interpolation.average);
|
||||
final paletteLab = _palette.map((p) => _rgbToLab(p.color)).toList();
|
||||
final token = ++_generationToken;
|
||||
setState(() => _isGenerating = true);
|
||||
|
||||
final List<int> assignments = List<int>.filled(gridW * gridH, 0);
|
||||
final payload = <String, dynamic>{
|
||||
'source': _sourceImageBytes!,
|
||||
'useCapSize': _useCapSize,
|
||||
'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()})
|
||||
.toList(growable: false),
|
||||
};
|
||||
|
||||
final out = await compute(_generateMosaicIsolate, payload);
|
||||
if (!mounted || token != _generationToken) return;
|
||||
|
||||
final palette = (out['palette'] as List)
|
||||
.map(
|
||||
(e) => CapColor(
|
||||
name: e['name'] as String, color: Color(e['value'] as int)),
|
||||
)
|
||||
.toList(growable: false);
|
||||
|
||||
final countsList = (out['counts'] as List).cast<int>();
|
||||
final counts = <CapColor, int>{};
|
||||
|
||||
for (int y = 0; y < gridH; y++) {
|
||||
for (int x = 0; x < gridW; x++) {
|
||||
final pix = scaled.getPixel(x, y);
|
||||
final srcColor = Color.fromARGB(255, pix.r.toInt(), pix.g.toInt(), pix.b.toInt());
|
||||
final srcLab = _rgbToLab(srcColor);
|
||||
|
||||
int bestIdx = 0;
|
||||
double bestDistance = double.infinity;
|
||||
for (int i = 0; i < _palette.length; i++) {
|
||||
final d = _deltaE76(srcLab, paletteLab[i]);
|
||||
if (d < bestDistance) {
|
||||
bestDistance = d;
|
||||
bestIdx = i;
|
||||
}
|
||||
for (int i = 0; i < palette.length; i++) {
|
||||
counts[palette[i]] = countsList[i];
|
||||
}
|
||||
|
||||
assignments[y * gridW + x] = bestIdx;
|
||||
final cap = _palette[bestIdx];
|
||||
counts[cap] = (counts[cap] ?? 0) + 1;
|
||||
}
|
||||
}
|
||||
final sortedCounts = counts.entries.toList()
|
||||
..sort((a, b) => b.value.compareTo(a.value));
|
||||
|
||||
setState(() {
|
||||
_result = MosaicResult(width: gridW, height: gridH, assignments: assignments, palette: List.of(_palette), counts: counts);
|
||||
_result = MosaicResult(
|
||||
width: out['width'] as int,
|
||||
height: out['height'] as int,
|
||||
assignments: (out['assignments'] as List).cast<int>(),
|
||||
palette: palette,
|
||||
counts: counts,
|
||||
sortedCounts: sortedCounts,
|
||||
previewPng: out['previewPng'] as Uint8List,
|
||||
);
|
||||
_isGenerating = false;
|
||||
});
|
||||
}
|
||||
|
||||
@@ -177,9 +262,15 @@ class _MosaicHomePageState extends State<MosaicHomePage> {
|
||||
return Scaffold(
|
||||
appBar: AppBar(title: const Text('Bottle-Cap Mosaic Prototype')),
|
||||
floatingActionButton: FloatingActionButton.extended(
|
||||
onPressed: _generate,
|
||||
icon: const Icon(Icons.auto_fix_high),
|
||||
label: const Text('Generate Mosaic'),
|
||||
onPressed: _isGenerating ? null : _generate,
|
||||
icon: _isGenerating
|
||||
? const SizedBox(
|
||||
width: 18,
|
||||
height: 18,
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
)
|
||||
: const Icon(Icons.auto_fix_high),
|
||||
label: Text(_isGenerating ? 'Generating…' : 'Generate Mosaic'),
|
||||
),
|
||||
body: Padding(
|
||||
padding: const EdgeInsets.all(12),
|
||||
@@ -205,7 +296,10 @@ class _MosaicHomePageState extends State<MosaicHomePage> {
|
||||
ButtonSegment(value: true, label: Text('Cap size (px)')),
|
||||
],
|
||||
selected: {_useCapSize},
|
||||
onSelectionChanged: (s) => setState(() => _useCapSize = s.first),
|
||||
onSelectionChanged: (s) {
|
||||
setState(() => _useCapSize = s.first);
|
||||
_scheduleRegenerate();
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
if (!_useCapSize)
|
||||
@@ -215,7 +309,8 @@ class _MosaicHomePageState extends State<MosaicHomePage> {
|
||||
child: TextField(
|
||||
controller: _gridWidthCtrl,
|
||||
keyboardType: TextInputType.number,
|
||||
decoration: const InputDecoration(labelText: 'Grid Width'),
|
||||
decoration:
|
||||
const InputDecoration(labelText: 'Grid Width'),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
@@ -223,7 +318,8 @@ class _MosaicHomePageState extends State<MosaicHomePage> {
|
||||
child: TextField(
|
||||
controller: _gridHeightCtrl,
|
||||
keyboardType: TextInputType.number,
|
||||
decoration: const InputDecoration(labelText: 'Grid Height'),
|
||||
decoration:
|
||||
const InputDecoration(labelText: 'Grid Height'),
|
||||
),
|
||||
),
|
||||
],
|
||||
@@ -232,13 +328,98 @@ class _MosaicHomePageState extends State<MosaicHomePage> {
|
||||
TextField(
|
||||
controller: _capSizeCtrl,
|
||||
keyboardType: TextInputType.number,
|
||||
decoration: const InputDecoration(labelText: 'Approx cap size in source image (pixels)'),
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Approx cap size in source image (pixels)',
|
||||
),
|
||||
),
|
||||
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(child: Text('Cap Palette', style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold))),
|
||||
IconButton(onPressed: _addCapDialog, icon: const Icon(Icons.add_circle_outline)),
|
||||
const Expanded(
|
||||
child: Text(
|
||||
'Cap Palette',
|
||||
style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
|
||||
),
|
||||
),
|
||||
IconButton(
|
||||
onPressed: _addCapDialog,
|
||||
icon: const Icon(Icons.add_circle_outline),
|
||||
),
|
||||
],
|
||||
),
|
||||
Wrap(
|
||||
@@ -249,35 +430,47 @@ class _MosaicHomePageState extends State<MosaicHomePage> {
|
||||
Chip(
|
||||
avatar: CircleAvatar(backgroundColor: _palette[i].color),
|
||||
label: Text(_palette[i].name),
|
||||
onDeleted: _palette.length <= 1 ? null : () => setState(() => _palette.removeAt(i)),
|
||||
onDeleted: _palette.length <= 1
|
||||
? null
|
||||
: () {
|
||||
setState(() => _palette.removeAt(i));
|
||||
_scheduleRegenerate();
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
if (_result != null) ...[
|
||||
Text('Preview (${_result!.width} x ${_result!.height})', style: const TextStyle(fontSize: 18, fontWeight: FontWeight.bold)),
|
||||
Text(
|
||||
'Preview (${_result!.width} x ${_result!.height})',
|
||||
style:
|
||||
const TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
AspectRatio(
|
||||
RepaintBoundary(
|
||||
child: AspectRatio(
|
||||
aspectRatio: _result!.width / _result!.height,
|
||||
child: CustomPaint(
|
||||
painter: MosaicPainter(_result!),
|
||||
child: Image.memory(
|
||||
_result!.previewPng,
|
||||
fit: BoxFit.fill,
|
||||
filterQuality: FilterQuality.none,
|
||||
gaplessPlayback: true,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
const Text('Bill of Materials', style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold)),
|
||||
...(() {
|
||||
final items = _result!.counts.entries.toList()..sort((a, b) => b.value.compareTo(a.value));
|
||||
return items
|
||||
.map(
|
||||
const Text(
|
||||
'Bill of Materials',
|
||||
style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
|
||||
),
|
||||
..._result!.sortedCounts.map(
|
||||
(e) => ListTile(
|
||||
dense: true,
|
||||
leading: CircleAvatar(backgroundColor: e.key.color),
|
||||
title: Text(e.key.name),
|
||||
trailing: Text('${e.value} caps'),
|
||||
),
|
||||
)
|
||||
.toList();
|
||||
})(),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
@@ -294,11 +487,48 @@ 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;
|
||||
|
||||
CapColor({required this.name, required this.color});
|
||||
const CapColor({required this.name, required this.color});
|
||||
}
|
||||
|
||||
class MosaicResult {
|
||||
@@ -307,38 +537,282 @@ class MosaicResult {
|
||||
final List<int> assignments;
|
||||
final List<CapColor> palette;
|
||||
final Map<CapColor, int> counts;
|
||||
final List<MapEntry<CapColor, int>> sortedCounts;
|
||||
final Uint8List previewPng;
|
||||
|
||||
MosaicResult({required this.width, required this.height, required this.assignments, required this.palette, required this.counts});
|
||||
MosaicResult({
|
||||
required this.width,
|
||||
required this.height,
|
||||
required this.assignments,
|
||||
required this.palette,
|
||||
required this.counts,
|
||||
required this.sortedCounts,
|
||||
required this.previewPng,
|
||||
});
|
||||
}
|
||||
|
||||
class MosaicPainter extends CustomPainter {
|
||||
final MosaicResult result;
|
||||
Map<String, dynamic> _generateMosaicIsolate(Map<String, dynamic> request) {
|
||||
final source = request['source'] as Uint8List;
|
||||
final useCapSize = request['useCapSize'] as bool;
|
||||
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>();
|
||||
|
||||
MosaicPainter(this.result);
|
||||
final decoded = img.decodeImage(source);
|
||||
if (decoded == null) {
|
||||
return <String, dynamic>{
|
||||
'width': 1,
|
||||
'height': 1,
|
||||
'assignments': <int>[0],
|
||||
'counts': <int>[1],
|
||||
'palette': paletteRaw,
|
||||
'previewPng': Uint8List.fromList(
|
||||
img.encodePng(img.Image(width: 1, height: 1)),
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
@override
|
||||
void paint(Canvas canvas, Size size) {
|
||||
final cellW = size.width / result.width;
|
||||
final cellH = size.height / result.height;
|
||||
final paint = Paint();
|
||||
final gridW = useCapSize
|
||||
? math.max(1, (decoded.width / capSize).round())
|
||||
: defaultGridW;
|
||||
final gridH = useCapSize
|
||||
? math.max(1, (decoded.height / capSize).round())
|
||||
: defaultGridH;
|
||||
|
||||
for (int y = 0; y < result.height; y++) {
|
||||
for (int x = 0; x < result.width; x++) {
|
||||
final idx = result.assignments[y * result.width + x];
|
||||
paint.color = result.palette[idx].color;
|
||||
canvas.drawRect(Rect.fromLTWH(x * cellW, y * cellH, cellW, cellH), paint);
|
||||
final interpolation = fidelityStructure < 0.4
|
||||
? img.Interpolation.average
|
||||
: img.Interpolation.linear;
|
||||
final scaled = img.copyResize(
|
||||
decoded,
|
||||
width: gridW,
|
||||
height: gridH,
|
||||
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(pixelCount, 0);
|
||||
final counts = List<int>.filled(paletteValues.length, 0);
|
||||
|
||||
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 idx = y * gridW + x;
|
||||
|
||||
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 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;
|
||||
}
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
bool shouldRepaint(covariant MosaicPainter oldDelegate) => oldDelegate.result != result;
|
||||
final preview = img.Image(width: gridW, height: gridH);
|
||||
for (int y = 0; y < gridH; y++) {
|
||||
for (int x = 0; x < gridW; x++) {
|
||||
final idx = assignments[y * gridW + x];
|
||||
final argb = paletteValues[idx];
|
||||
preview.setPixelRgba(
|
||||
x,
|
||||
y,
|
||||
(argb >> 16) & 0xFF,
|
||||
(argb >> 8) & 0xFF,
|
||||
argb & 0xFF,
|
||||
255,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return <String, dynamic>{
|
||||
'width': gridW,
|
||||
'height': gridH,
|
||||
'assignments': assignments,
|
||||
'counts': counts,
|
||||
'palette': paletteRaw,
|
||||
'previewPng': Uint8List.fromList(img.encodePng(preview, level: 1)),
|
||||
};
|
||||
}
|
||||
|
||||
List<double> _rgbToLab(Color c) {
|
||||
double r = c.r / 255.0;
|
||||
double g = c.g / 255.0;
|
||||
double b = c.b / 255.0;
|
||||
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);
|
||||
}
|
||||
|
||||
List<double> _rgbToLab(int rgb) {
|
||||
double r = ((rgb >> 16) & 0xFF) / 255.0;
|
||||
double g = ((rgb >> 8) & 0xFF) / 255.0;
|
||||
double b = (rgb & 0xFF) / 255.0;
|
||||
|
||||
r = r <= 0.04045 ? r / 12.92 : math.pow((r + 0.055) / 1.055, 2.4).toDouble();
|
||||
g = g <= 0.04045 ? g / 12.92 : math.pow((g + 0.055) / 1.055, 2.4).toDouble();
|
||||
@@ -348,7 +822,9 @@ List<double> _rgbToLab(Color c) {
|
||||
final y = (r * 0.2126 + g * 0.7152 + b * 0.0722) / 1.00000;
|
||||
final z = (r * 0.0193 + g * 0.1192 + b * 0.9505) / 1.08883;
|
||||
|
||||
double f(double t) => t > 0.008856 ? math.pow(t, 1.0 / 3.0).toDouble() : (7.787 * t) + (16 / 116);
|
||||
double f(double t) => t > 0.008856
|
||||
? math.pow(t, 1.0 / 3.0).toDouble()
|
||||
: (7.787 * t) + (16 / 116);
|
||||
|
||||
final fx = f(x);
|
||||
final fy = f(y);
|
||||
|
||||
Reference in New Issue
Block a user