diff --git a/lib/main.dart b/lib/main.dart index ca6f4a1..553fcc2 100644 --- a/lib/main.dart +++ b/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; @@ -32,13 +34,18 @@ class MosaicHomePage extends StatefulWidget { class _MosaicHomePageState extends State { 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; final List _palette = [ CapColor(name: 'White', color: const Color(0xFFF2F2F2)), @@ -48,8 +55,17 @@ class _MosaicHomePageState extends State { 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 +82,19 @@ class _MosaicHomePageState extends State { }); } + void _scheduleRegenerate() { + if (_sourceImageBytes == null || _result == null) return; + _debounceTimer?.cancel(); + _debounceTimer = Timer(const Duration(milliseconds: 300), _generate); + } + 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( context: context, @@ -98,22 +123,30 @@ class _MosaicHomePageState extends State { 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 +156,58 @@ class _MosaicHomePageState extends State { ); } - void _generate() { + Future _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 assignments = List.filled(gridW * gridH, 0); + final payload = { + 'source': _sourceImageBytes!, + 'useCapSize': _useCapSize, + 'gridW': gridW, + 'gridH': gridH, + 'capSize': capSize, + 'palette': _palette + .map((p) => + {'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(); final counts = {}; - - 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; - } - } - - assignments[y * gridW + x] = bestIdx; - final cap = _palette[bestIdx]; - counts[cap] = (counts[cap] ?? 0) + 1; - } + for (int i = 0; i < palette.length; i++) { + counts[palette[i]] = countsList[i]; } + 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(), + palette: palette, + counts: counts, + sortedCounts: sortedCounts, + previewPng: out['previewPng'] as Uint8List, + ); + _isGenerating = false; }); } @@ -177,9 +216,15 @@ class _MosaicHomePageState extends State { 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 +250,10 @@ class _MosaicHomePageState extends State { 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 +263,8 @@ class _MosaicHomePageState extends State { 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 +272,8 @@ class _MosaicHomePageState extends State { child: TextField( controller: _gridHeightCtrl, keyboardType: TextInputType.number, - decoration: const InputDecoration(labelText: 'Grid Height'), + decoration: + const InputDecoration(labelText: 'Grid Height'), ), ), ], @@ -232,13 +282,23 @@ class _MosaicHomePageState extends State { 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), 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 +309,47 @@ class _MosaicHomePageState extends State { 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( - aspectRatio: _result!.width / _result!.height, - child: CustomPaint( - painter: MosaicPainter(_result!), + RepaintBoundary( + child: AspectRatio( + aspectRatio: _result!.width / _result!.height, + 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( - (e) => ListTile( - dense: true, - leading: CircleAvatar(backgroundColor: e.key.color), - title: Text(e.key.name), - trailing: Text('${e.value} caps'), - ), - ) - .toList(); - })(), + 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'), + ), + ), ], ], ), @@ -298,7 +370,7 @@ 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 +379,130 @@ class MosaicResult { final List assignments; final List palette; final Map counts; + final List> 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 _generateMosaicIsolate(Map 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 paletteRaw = (request['palette'] as List).cast(); - MosaicPainter(this.result); + final decoded = img.decodeImage(source); + if (decoded == null) { + return { + 'width': 1, + 'height': 1, + 'assignments': [0], + 'counts': [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 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.filled(gridW * gridH, 0); + final counts = List.filled(paletteValues.length, 0); + + final nearestCache = {}; + final labCache = >{}; + + 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; } + + final srcLab = labCache.putIfAbsent(rgb, () => _rgbToLab(rgb)); + + int bestIdx = 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]++; } } - @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 { + 'width': gridW, + 'height': gridH, + 'assignments': assignments, + 'counts': counts, + 'palette': paletteRaw, + 'previewPng': Uint8List.fromList(img.encodePng(preview, level: 1)), + }; } -List _rgbToLab(Color c) { - double r = c.r / 255.0; - double g = c.g / 255.0; - double b = c.b / 255.0; +List _argbToLab(int argb) { + final rgb = argb & 0x00FFFFFF; + return _rgbToLab(rgb); +} + +List _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 +512,9 @@ List _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);