Optimize mosaic generation and preview performance
This commit is contained in:
356
lib/main.dart
356
lib/main.dart
@@ -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 cap = _palette[bestIdx];
|
|
||||||
counts[cap] = (counts[cap] ?? 0) + 1;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
final sortedCounts = counts.entries.toList()
|
||||||
|
..sort((a, b) => b.value.compareTo(a.value));
|
||||||
|
|
||||||
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(
|
||||||
aspectRatio: _result!.width / _result!.height,
|
child: AspectRatio(
|
||||||
child: CustomPaint(
|
aspectRatio: _result!.width / _result!.height,
|
||||||
painter: MosaicPainter(_result!),
|
child: Image.memory(
|
||||||
|
_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) {
|
||||||
|
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
|
final gridW = useCapSize
|
||||||
void paint(Canvas canvas, Size size) {
|
? math.max(1, (decoded.width / capSize).round())
|
||||||
final cellW = size.width / result.width;
|
: defaultGridW;
|
||||||
final cellH = size.height / result.height;
|
final gridH = useCapSize
|
||||||
final paint = Paint();
|
? math.max(1, (decoded.height / capSize).round())
|
||||||
|
: defaultGridH;
|
||||||
|
|
||||||
for (int y = 0; y < result.height; y++) {
|
final scaled = img.copyResize(
|
||||||
for (int x = 0; x < result.width; x++) {
|
decoded,
|
||||||
final idx = result.assignments[y * result.width + x];
|
width: gridW,
|
||||||
paint.color = result.palette[idx].color;
|
height: gridH,
|
||||||
canvas.drawRect(Rect.fromLTWH(x * cellW, y * cellH, cellW, cellH), paint);
|
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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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
|
final preview = img.Image(width: gridW, height: gridH);
|
||||||
bool shouldRepaint(covariant MosaicPainter oldDelegate) => oldDelegate.result != result;
|
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) {
|
List<double> _argbToLab(int argb) {
|
||||||
double r = c.r / 255.0;
|
final rgb = argb & 0x00FFFFFF;
|
||||||
double g = c.g / 255.0;
|
return _rgbToLab(rgb);
|
||||||
double b = c.b / 255.0;
|
}
|
||||||
|
|
||||||
|
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);
|
||||||
|
|||||||
Reference in New Issue
Block a user