Optimize mosaic generation and preview performance

This commit is contained in:
gary
2026-02-21 20:12:57 +01:00
parent d65875ba84
commit a00d456d03

View File

@@ -1,6 +1,8 @@
import 'dart:async';
import 'dart:math' as math; import 'dart:math' as math;
import 'dart:typed_data'; import 'dart:typed_data';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_colorpicker/flutter_colorpicker.dart'; import 'package:flutter_colorpicker/flutter_colorpicker.dart';
import 'package:image/image.dart' as img; import 'package:image/image.dart' as img;
@@ -32,13 +34,18 @@ class MosaicHomePage extends StatefulWidget {
class _MosaicHomePageState extends State<MosaicHomePage> { class _MosaicHomePageState extends State<MosaicHomePage> {
final ImagePicker _picker = ImagePicker(); final ImagePicker _picker = ImagePicker();
final TextEditingController _gridWidthCtrl = TextEditingController(text: '40'); final TextEditingController _gridWidthCtrl =
final TextEditingController _gridHeightCtrl = TextEditingController(text: '30'); TextEditingController(text: '40');
final TextEditingController _gridHeightCtrl =
TextEditingController(text: '30');
final TextEditingController _capSizeCtrl = TextEditingController(text: '12'); final TextEditingController _capSizeCtrl = TextEditingController(text: '12');
Uint8List? _sourceImageBytes; Uint8List? _sourceImageBytes;
MosaicResult? _result; MosaicResult? _result;
bool _useCapSize = false; bool _useCapSize = false;
bool _isGenerating = false;
Timer? _debounceTimer;
int _generationToken = 0;
final List<CapColor> _palette = [ final List<CapColor> _palette = [
CapColor(name: 'White', color: const Color(0xFFF2F2F2)), CapColor(name: 'White', color: const Color(0xFFF2F2F2)),
@@ -48,8 +55,17 @@ class _MosaicHomePageState extends State<MosaicHomePage> {
CapColor(name: 'Green', color: const Color(0xFF4FAE63)), CapColor(name: 'Green', color: const Color(0xFF4FAE63)),
]; ];
@override
void initState() {
super.initState();
_gridWidthCtrl.addListener(_scheduleRegenerate);
_gridHeightCtrl.addListener(_scheduleRegenerate);
_capSizeCtrl.addListener(_scheduleRegenerate);
}
@override @override
void dispose() { void dispose() {
_debounceTimer?.cancel();
_gridWidthCtrl.dispose(); _gridWidthCtrl.dispose();
_gridHeightCtrl.dispose(); _gridHeightCtrl.dispose();
_capSizeCtrl.dispose(); _capSizeCtrl.dispose();
@@ -66,10 +82,19 @@ class _MosaicHomePageState extends State<MosaicHomePage> {
}); });
} }
void _scheduleRegenerate() {
if (_sourceImageBytes == null || _result == null) return;
_debounceTimer?.cancel();
_debounceTimer = Timer(const Duration(milliseconds: 300), _generate);
}
void _addCapDialog() { void _addCapDialog() {
Color selected = Colors.orange; Color selected = Colors.orange;
final nameCtrl = TextEditingController(); 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>( showDialog<void>(
context: context, context: context,
@@ -98,22 +123,30 @@ class _MosaicHomePageState extends State<MosaicHomePage> {
pickerColor: selected, pickerColor: selected,
onColorChanged: (c) { onColorChanged: (c) {
selected = c; selected = c;
hexCtrl.text = '#${c.toARGB32().toRadixString(16).substring(2).toUpperCase()}'; hexCtrl.text =
'#${c.toARGB32().toRadixString(16).substring(2).toUpperCase()}';
}, },
), ),
], ],
), ),
), ),
actions: [ actions: [
TextButton(onPressed: () => Navigator.pop(ctx), child: const Text('Cancel')), TextButton(
onPressed: () => Navigator.pop(ctx),
child: const Text('Cancel'),
),
FilledButton( FilledButton(
onPressed: () { onPressed: () {
final name = nameCtrl.text.trim(); final name = nameCtrl.text.trim();
if (name.isEmpty) return; if (name.isEmpty) return;
setState(() { setState(() {
_palette.add(CapColor(name: name, color: _parseHex(hexCtrl.text) ?? selected)); _palette.add(
CapColor(
name: name, color: _parseHex(hexCtrl.text) ?? selected),
);
}); });
Navigator.pop(ctx); Navigator.pop(ctx);
_scheduleRegenerate();
}, },
child: const Text('Add'), child: const Text('Add'),
), ),
@@ -123,52 +156,58 @@ class _MosaicHomePageState extends State<MosaicHomePage> {
); );
} }
void _generate() { Future<void> _generate() async {
if (_sourceImageBytes == null || _palette.isEmpty) return; if (_sourceImageBytes == null || _palette.isEmpty) return;
final decoded = img.decodeImage(_sourceImageBytes!);
if (decoded == null) return;
final int gridW; final int gridW = math.max(1, int.tryParse(_gridWidthCtrl.text) ?? 40);
final int gridH; final int gridH = math.max(1, int.tryParse(_gridHeightCtrl.text) ?? 30);
if (_useCapSize) { final int capSize = math.max(1, int.tryParse(_capSizeCtrl.text) ?? 12);
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 scaled = img.copyResize(decoded, width: gridW, height: gridH, interpolation: img.Interpolation.average); final token = ++_generationToken;
final paletteLab = _palette.map((p) => _rgbToLab(p.color)).toList(); 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,
'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>{}; final counts = <CapColor, int>{};
for (int i = 0; i < palette.length; i++) {
for (int y = 0; y < gridH; y++) { counts[palette[i]] = countsList[i];
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;
}
} }
assignments[y * gridW + x] = bestIdx; final sortedCounts = counts.entries.toList()
final cap = _palette[bestIdx]; ..sort((a, b) => b.value.compareTo(a.value));
counts[cap] = (counts[cap] ?? 0) + 1;
}
}
setState(() { 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 +216,15 @@ class _MosaicHomePageState extends State<MosaicHomePage> {
return Scaffold( return Scaffold(
appBar: AppBar(title: const Text('Bottle-Cap Mosaic Prototype')), appBar: AppBar(title: const Text('Bottle-Cap Mosaic Prototype')),
floatingActionButton: FloatingActionButton.extended( floatingActionButton: FloatingActionButton.extended(
onPressed: _generate, onPressed: _isGenerating ? null : _generate,
icon: const Icon(Icons.auto_fix_high), icon: _isGenerating
label: const Text('Generate Mosaic'), ? const SizedBox(
width: 18,
height: 18,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Icon(Icons.auto_fix_high),
label: Text(_isGenerating ? 'Generating…' : 'Generate Mosaic'),
), ),
body: Padding( body: Padding(
padding: const EdgeInsets.all(12), padding: const EdgeInsets.all(12),
@@ -205,7 +250,10 @@ class _MosaicHomePageState extends State<MosaicHomePage> {
ButtonSegment(value: true, label: Text('Cap size (px)')), ButtonSegment(value: true, label: Text('Cap size (px)')),
], ],
selected: {_useCapSize}, selected: {_useCapSize},
onSelectionChanged: (s) => setState(() => _useCapSize = s.first), onSelectionChanged: (s) {
setState(() => _useCapSize = s.first);
_scheduleRegenerate();
},
), ),
const SizedBox(height: 8), const SizedBox(height: 8),
if (!_useCapSize) if (!_useCapSize)
@@ -215,7 +263,8 @@ class _MosaicHomePageState extends State<MosaicHomePage> {
child: TextField( child: TextField(
controller: _gridWidthCtrl, controller: _gridWidthCtrl,
keyboardType: TextInputType.number, keyboardType: TextInputType.number,
decoration: const InputDecoration(labelText: 'Grid Width'), decoration:
const InputDecoration(labelText: 'Grid Width'),
), ),
), ),
const SizedBox(width: 8), const SizedBox(width: 8),
@@ -223,7 +272,8 @@ class _MosaicHomePageState extends State<MosaicHomePage> {
child: TextField( child: TextField(
controller: _gridHeightCtrl, controller: _gridHeightCtrl,
keyboardType: TextInputType.number, keyboardType: TextInputType.number,
decoration: const InputDecoration(labelText: 'Grid Height'), decoration:
const InputDecoration(labelText: 'Grid Height'),
), ),
), ),
], ],
@@ -232,13 +282,23 @@ class _MosaicHomePageState extends State<MosaicHomePage> {
TextField( TextField(
controller: _capSizeCtrl, controller: _capSizeCtrl,
keyboardType: TextInputType.number, 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 SizedBox(height: 16),
Row( Row(
children: [ children: [
const Expanded(child: Text('Cap Palette', style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold))), const Expanded(
IconButton(onPressed: _addCapDialog, icon: const Icon(Icons.add_circle_outline)), child: Text(
'Cap Palette',
style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
),
),
IconButton(
onPressed: _addCapDialog,
icon: const Icon(Icons.add_circle_outline),
),
], ],
), ),
Wrap( Wrap(
@@ -249,35 +309,47 @@ class _MosaicHomePageState extends State<MosaicHomePage> {
Chip( Chip(
avatar: CircleAvatar(backgroundColor: _palette[i].color), avatar: CircleAvatar(backgroundColor: _palette[i].color),
label: Text(_palette[i].name), 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), const SizedBox(height: 16),
if (_result != null) ...[ 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), const SizedBox(height: 8),
AspectRatio( RepaintBoundary(
child: AspectRatio(
aspectRatio: _result!.width / _result!.height, aspectRatio: _result!.width / _result!.height,
child: CustomPaint( child: Image.memory(
painter: MosaicPainter(_result!), _result!.previewPng,
fit: BoxFit.fill,
filterQuality: FilterQuality.none,
gaplessPlayback: true,
),
), ),
), ),
const SizedBox(height: 16), const SizedBox(height: 16),
const Text('Bill of Materials', style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold)), const Text(
...(() { 'Bill of Materials',
final items = _result!.counts.entries.toList()..sort((a, b) => b.value.compareTo(a.value)); style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
return items ),
.map( ..._result!.sortedCounts.map(
(e) => ListTile( (e) => ListTile(
dense: true, dense: true,
leading: CircleAvatar(backgroundColor: e.key.color), leading: CircleAvatar(backgroundColor: e.key.color),
title: Text(e.key.name), title: Text(e.key.name),
trailing: Text('${e.value} caps'), trailing: Text('${e.value} caps'),
), ),
) ),
.toList();
})(),
], ],
], ],
), ),
@@ -298,7 +370,7 @@ class CapColor {
final String name; final String name;
final Color color; final Color color;
CapColor({required this.name, required this.color}); const CapColor({required this.name, required this.color});
} }
class MosaicResult { class MosaicResult {
@@ -307,38 +379,130 @@ class MosaicResult {
final List<int> assignments; final List<int> assignments;
final List<CapColor> palette; final List<CapColor> palette;
final Map<CapColor, int> counts; 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 { Map<String, dynamic> _generateMosaicIsolate(Map<String, dynamic> request) {
final MosaicResult result; 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 paletteRaw = (request['palette'] as List).cast<Map>();
MosaicPainter(this.result); final decoded = img.decodeImage(source);
if (decoded == null) {
@override return <String, dynamic>{
void paint(Canvas canvas, Size size) { 'width': 1,
final cellW = size.width / result.width; 'height': 1,
final cellH = size.height / result.height; 'assignments': <int>[0],
final paint = Paint(); 'counts': <int>[1],
'palette': paletteRaw,
for (int y = 0; y < result.height; y++) { 'previewPng': Uint8List.fromList(
for (int x = 0; x < result.width; x++) { img.encodePng(img.Image(width: 1, height: 1)),
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);
}
}
} }
@override final gridW = useCapSize
bool shouldRepaint(covariant MosaicPainter oldDelegate) => oldDelegate.result != result; ? math.max(1, (decoded.width / capSize).round())
: defaultGridW;
final gridH = useCapSize
? math.max(1, (decoded.height / capSize).round())
: defaultGridH;
final scaled = img.copyResize(
decoded,
width: gridW,
height: gridH,
interpolation: img.Interpolation.average,
);
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 counts = List<int>.filled(paletteValues.length, 0);
final nearestCache = <int, int>{};
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 cached = nearestCache[rgb];
if (cached != null) {
assignments[y * gridW + x] = cached;
counts[cached]++;
continue;
} }
List<double> _rgbToLab(Color c) { final srcLab = labCache.putIfAbsent(rgb, () => _rgbToLab(rgb));
double r = c.r / 255.0;
double g = c.g / 255.0; int bestIdx = 0;
double b = c.b / 255.0; double bestDistance = double.infinity;
for (int i = 0; i < paletteLab.length; i++) {
final d = _deltaE76(srcLab, paletteLab[i]);
if (d < bestDistance) {
bestDistance = d;
bestIdx = i;
}
}
nearestCache[rgb] = bestIdx;
assignments[y * gridW + x] = bestIdx;
counts[bestIdx]++;
}
}
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> _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(); 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(); g = g <= 0.04045 ? g / 12.92 : math.pow((g + 0.055) / 1.055, 2.4).toDouble();
@@ -348,7 +512,9 @@ List<double> _rgbToLab(Color c) {
final y = (r * 0.2126 + g * 0.7152 + b * 0.0722) / 1.00000; 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; 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 fx = f(x);
final fy = f(y); final fy = f(y);