Improve cap photo color extraction with circle masking
This commit is contained in:
@@ -13,7 +13,8 @@ Prototype Flutter app for generating bottle-cap mosaics from imported images.
|
|||||||
- survives app restarts
|
- survives app restarts
|
||||||
- Catalog management:
|
- Catalog management:
|
||||||
- add entries manually (name + hex/color picker + optional photo)
|
- add entries manually (name + hex/color picker + optional photo)
|
||||||
- **Deckel fotografieren**: capture a cap with camera, auto-detect dominant center color, store cropped thumbnail
|
- **Deckel fotografieren**: robust circular cap detection (edge-based circle search with fallback), color is computed only from masked cap interior pixels
|
||||||
|
- selectable extraction mode in photo review dialog: **Dominante Farbe** or **Durchschnitt**
|
||||||
- dedicated catalog browser with **list/grid** modes
|
- dedicated catalog browser with **list/grid** modes
|
||||||
- edit existing entry name/color
|
- edit existing entry name/color
|
||||||
- delete entries (with thumbnail cleanup)
|
- delete entries (with thumbnail cleanup)
|
||||||
|
|||||||
272
lib/main.dart
272
lib/main.dart
@@ -2,7 +2,6 @@ import 'dart:async';
|
|||||||
import 'dart:convert';
|
import 'dart:convert';
|
||||||
import 'dart:io';
|
import 'dart:io';
|
||||||
import 'dart:math' as math;
|
import 'dart:math' as math;
|
||||||
import 'dart:typed_data';
|
|
||||||
|
|
||||||
import 'package:flutter/foundation.dart';
|
import 'package:flutter/foundation.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
@@ -32,6 +31,8 @@ enum StylePreset { realistisch, ausgewogen, kuenstlerisch }
|
|||||||
|
|
||||||
enum CatalogViewMode { list, grid }
|
enum CatalogViewMode { list, grid }
|
||||||
|
|
||||||
|
enum ColorExtractionMode { dominant, average }
|
||||||
|
|
||||||
class MosaicHomePage extends StatefulWidget {
|
class MosaicHomePage extends StatefulWidget {
|
||||||
const MosaicHomePage({super.key});
|
const MosaicHomePage({super.key});
|
||||||
|
|
||||||
@@ -57,6 +58,7 @@ class _MosaicHomePageState extends State<MosaicHomePage> {
|
|||||||
int _generationToken = 0;
|
int _generationToken = 0;
|
||||||
bool _isCatalogLoaded = false;
|
bool _isCatalogLoaded = false;
|
||||||
CatalogViewMode _catalogViewMode = CatalogViewMode.grid;
|
CatalogViewMode _catalogViewMode = CatalogViewMode.grid;
|
||||||
|
ColorExtractionMode _colorExtractionMode = ColorExtractionMode.dominant;
|
||||||
|
|
||||||
double _fidelityStructure = 0.5;
|
double _fidelityStructure = 0.5;
|
||||||
double _ditheringStrength = 0.35;
|
double _ditheringStrength = 0.35;
|
||||||
@@ -154,11 +156,16 @@ class _MosaicHomePageState extends State<MosaicHomePage> {
|
|||||||
if (captured == null) return;
|
if (captured == null) return;
|
||||||
|
|
||||||
final bytes = await captured.readAsBytes();
|
final bytes = await captured.readAsBytes();
|
||||||
final detected = await compute(_extractCapFromCenterIsolate, bytes);
|
final detected = await compute(_extractCapFromPhotoIsolate, bytes);
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
|
|
||||||
Color selected = Color(detected['color'] as int);
|
final dominantColor = Color(detected['dominantColor'] as int);
|
||||||
|
final averageColor = Color(detected['averageColor'] as int);
|
||||||
final previewBytes = detected['previewPng'] as Uint8List;
|
final previewBytes = detected['previewPng'] as Uint8List;
|
||||||
|
final bool usedFallback = detected['usedFallback'] as bool? ?? false;
|
||||||
|
ColorExtractionMode mode = _colorExtractionMode;
|
||||||
|
Color selected =
|
||||||
|
mode == ColorExtractionMode.dominant ? dominantColor : averageColor;
|
||||||
_photoCapNameCtrl.text = 'Fotografierter Deckel';
|
_photoCapNameCtrl.text = 'Fotografierter Deckel';
|
||||||
_photoCapHexCtrl.text = _colorToHex(selected);
|
_photoCapHexCtrl.text = _colorToHex(selected);
|
||||||
|
|
||||||
@@ -181,7 +188,36 @@ class _MosaicHomePageState extends State<MosaicHomePage> {
|
|||||||
),
|
),
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
Text(
|
Text(
|
||||||
'Farbe wird aus dem mittigen Kreisbereich erkannt.',
|
usedFallback
|
||||||
|
? 'Kreis wurde per Kanten-Fallback erkannt.'
|
||||||
|
: 'Kreis wurde automatisch auf dem Deckel erkannt.',
|
||||||
|
style: Theme.of(context).textTheme.bodySmall,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
SegmentedButton<ColorExtractionMode>(
|
||||||
|
segments: const [
|
||||||
|
ButtonSegment(
|
||||||
|
value: ColorExtractionMode.dominant,
|
||||||
|
label: Text('Dominante Farbe'),
|
||||||
|
),
|
||||||
|
ButtonSegment(
|
||||||
|
value: ColorExtractionMode.average,
|
||||||
|
label: Text('Durchschnitt'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
selected: {mode},
|
||||||
|
onSelectionChanged: (selection) {
|
||||||
|
mode = selection.first;
|
||||||
|
selected = mode == ColorExtractionMode.dominant
|
||||||
|
? dominantColor
|
||||||
|
: averageColor;
|
||||||
|
_photoCapHexCtrl.text = _colorToHex(selected);
|
||||||
|
setDialogState(() {});
|
||||||
|
},
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Text(
|
||||||
|
'Nur Pixel innerhalb des erkannten Kreisinneren werden ausgewertet.',
|
||||||
style: Theme.of(context).textTheme.bodySmall,
|
style: Theme.of(context).textTheme.bodySmall,
|
||||||
),
|
),
|
||||||
const SizedBox(height: 12),
|
const SizedBox(height: 12),
|
||||||
@@ -235,12 +271,14 @@ class _MosaicHomePageState extends State<MosaicHomePage> {
|
|||||||
final parsed = _parseHex(_photoCapHexCtrl.text);
|
final parsed = _parseHex(_photoCapHexCtrl.text);
|
||||||
final entry = CapCatalogEntry.newEntry(
|
final entry = CapCatalogEntry.newEntry(
|
||||||
name: name, color: parsed ?? selected);
|
name: name, color: parsed ?? selected);
|
||||||
|
_colorExtractionMode = mode;
|
||||||
entry.imagePath =
|
entry.imagePath =
|
||||||
await _saveThumbnail(previewBytes, entry.id);
|
await _saveThumbnail(previewBytes, entry.id);
|
||||||
_catalog.add(entry);
|
_catalog.add(entry);
|
||||||
await _persistCatalog();
|
await _persistCatalog();
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
setState(() {});
|
setState(() {});
|
||||||
|
if (!ctx.mounted) return;
|
||||||
Navigator.pop(ctx);
|
Navigator.pop(ctx);
|
||||||
_scheduleRegenerate();
|
_scheduleRegenerate();
|
||||||
},
|
},
|
||||||
@@ -321,8 +359,9 @@ class _MosaicHomePageState extends State<MosaicHomePage> {
|
|||||||
const InputDecoration(labelText: 'Hex (#RRGGBB)'),
|
const InputDecoration(labelText: 'Hex (#RRGGBB)'),
|
||||||
onChanged: (value) {
|
onChanged: (value) {
|
||||||
final parsed = _parseHex(value);
|
final parsed = _parseHex(value);
|
||||||
if (parsed != null)
|
if (parsed != null) {
|
||||||
setDialogState(() => selected = parsed);
|
setDialogState(() => selected = parsed);
|
||||||
|
}
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
@@ -369,6 +408,7 @@ class _MosaicHomePageState extends State<MosaicHomePage> {
|
|||||||
await _persistCatalog();
|
await _persistCatalog();
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
setState(() {});
|
setState(() {});
|
||||||
|
if (!ctx.mounted) return;
|
||||||
Navigator.pop(ctx);
|
Navigator.pop(ctx);
|
||||||
_scheduleRegenerate();
|
_scheduleRegenerate();
|
||||||
},
|
},
|
||||||
@@ -433,6 +473,7 @@ class _MosaicHomePageState extends State<MosaicHomePage> {
|
|||||||
await _persistCatalog();
|
await _persistCatalog();
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
setState(() {});
|
setState(() {});
|
||||||
|
if (!ctx.mounted) return;
|
||||||
Navigator.pop(ctx);
|
Navigator.pop(ctx);
|
||||||
_scheduleRegenerate();
|
_scheduleRegenerate();
|
||||||
},
|
},
|
||||||
@@ -1195,43 +1236,161 @@ Map<String, dynamic> _generateMosaicIsolate(Map<String, dynamic> request) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
Map<String, dynamic> _extractCapFromCenterIsolate(Uint8List sourceBytes) {
|
Map<String, dynamic> _extractCapFromPhotoIsolate(Uint8List sourceBytes) {
|
||||||
final decoded = img.decodeImage(sourceBytes);
|
final decoded = img.decodeImage(sourceBytes);
|
||||||
if (decoded == null) {
|
if (decoded == null) {
|
||||||
return {
|
return {
|
||||||
'color': Colors.orange.toARGB32(),
|
'dominantColor': Colors.orange.toARGB32(),
|
||||||
|
'averageColor': Colors.orange.toARGB32(),
|
||||||
|
'usedFallback': true,
|
||||||
'previewPng': Uint8List.fromList(
|
'previewPng': Uint8List.fromList(
|
||||||
img.encodePng(img.Image(width: 1, height: 1), level: 1)),
|
img.encodePng(img.Image(width: 1, height: 1), level: 1)),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
final cropSize = math.min(decoded.width, decoded.height);
|
const int analysisMaxSize = 480;
|
||||||
final startX = (decoded.width - cropSize) ~/ 2;
|
final double scale = decoded.width >= decoded.height
|
||||||
final startY = (decoded.height - cropSize) ~/ 2;
|
? (decoded.width > analysisMaxSize
|
||||||
final centered = img.copyCrop(decoded,
|
? analysisMaxSize / decoded.width
|
||||||
x: startX, y: startY, width: cropSize, height: cropSize);
|
: 1.0)
|
||||||
|
: (decoded.height > analysisMaxSize
|
||||||
|
? analysisMaxSize / decoded.height
|
||||||
|
: 1.0);
|
||||||
|
|
||||||
final analysisSize = centered.width > 420
|
final analysis = scale < 1.0
|
||||||
? img.copyResize(centered, width: 420, height: 420)
|
? img.copyResize(decoded,
|
||||||
: centered;
|
width: (decoded.width * scale).round(),
|
||||||
|
height: (decoded.height * scale).round(),
|
||||||
|
interpolation: img.Interpolation.average)
|
||||||
|
: decoded;
|
||||||
|
|
||||||
final cx = analysisSize.width / 2;
|
final detectedMaybe = _detectCapCircle(analysis);
|
||||||
final cy = analysisSize.height / 2;
|
final detected =
|
||||||
final radius = math.min(analysisSize.width, analysisSize.height) * 0.30;
|
detectedMaybe ?? _fallbackCapCircle(analysis.width, analysis.height);
|
||||||
final r2 = radius * radius;
|
final usedFallback = detectedMaybe == null;
|
||||||
|
|
||||||
|
final upscaledCircle = _DetectedCircle(
|
||||||
|
cx: detected.cx / scale,
|
||||||
|
cy: detected.cy / scale,
|
||||||
|
r: detected.r / scale,
|
||||||
|
);
|
||||||
|
|
||||||
|
final stats = _sampleCapColors(decoded, upscaledCircle);
|
||||||
|
|
||||||
|
final preview = img.copyResize(analysis,
|
||||||
|
width: math.min(analysis.width, 320),
|
||||||
|
height: (math.min(analysis.width, 320) * analysis.height / analysis.width)
|
||||||
|
.round());
|
||||||
|
final previewScale = preview.width / analysis.width;
|
||||||
|
_drawCircle(preview, detected.cx * previewScale, detected.cy * previewScale,
|
||||||
|
detected.r * previewScale);
|
||||||
|
|
||||||
|
return {
|
||||||
|
'dominantColor': stats.dominantArgb,
|
||||||
|
'averageColor': stats.averageArgb,
|
||||||
|
'usedFallback': usedFallback,
|
||||||
|
'previewPng': Uint8List.fromList(img.encodePng(preview, level: 1)),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
_DetectedCircle? _detectCapCircle(img.Image image) {
|
||||||
|
final width = image.width;
|
||||||
|
final height = image.height;
|
||||||
|
if (width < 40 || height < 40) return null;
|
||||||
|
|
||||||
|
final gray = List<double>.filled(width * height, 0);
|
||||||
|
for (int y = 0; y < height; y++) {
|
||||||
|
for (int x = 0; x < width; x++) {
|
||||||
|
final p = image.getPixel(x, y);
|
||||||
|
gray[y * width + x] = 0.2126 * p.r + 0.7152 * p.g + 0.0722 * p.b;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
final edge = List<double>.filled(width * height, 0);
|
||||||
|
double edgeMax = 1;
|
||||||
|
for (int y = 1; y < height - 1; y++) {
|
||||||
|
for (int x = 1; x < width - 1; x++) {
|
||||||
|
final gx = gray[y * width + (x + 1)] - gray[y * width + (x - 1)];
|
||||||
|
final gy = gray[(y + 1) * width + x] - gray[(y - 1) * width + x];
|
||||||
|
final m = math.sqrt(gx * gx + gy * gy);
|
||||||
|
edge[y * width + x] = m;
|
||||||
|
if (m > edgeMax) edgeMax = m;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
final minRadius = (math.min(width, height) * 0.14).round();
|
||||||
|
final maxRadius = (math.min(width, height) * 0.48).round();
|
||||||
|
if (maxRadius <= minRadius) return null;
|
||||||
|
|
||||||
|
_DetectedCircle? best;
|
||||||
|
double bestScore = 0;
|
||||||
|
|
||||||
|
for (int r = minRadius; r <= maxRadius; r += 4) {
|
||||||
|
final centerStep = math.max(4, r ~/ 7);
|
||||||
|
final samples = math.max(24, (2 * math.pi * r / 5).round());
|
||||||
|
|
||||||
|
for (int cy = r; cy < height - r; cy += centerStep) {
|
||||||
|
for (int cx = r; cx < width - r; cx += centerStep) {
|
||||||
|
double ringScore = 0;
|
||||||
|
int valid = 0;
|
||||||
|
for (int i = 0; i < samples; i++) {
|
||||||
|
final t = (2 * math.pi * i) / samples;
|
||||||
|
final x = (cx + r * math.cos(t)).round();
|
||||||
|
final y = (cy + r * math.sin(t)).round();
|
||||||
|
if (x < 1 || x >= width - 1 || y < 1 || y >= height - 1) continue;
|
||||||
|
ringScore += edge[y * width + x] / edgeMax;
|
||||||
|
valid++;
|
||||||
|
}
|
||||||
|
if (valid < samples * 0.7) continue;
|
||||||
|
|
||||||
|
final normalizedRing = ringScore / valid;
|
||||||
|
if (normalizedRing < 0.22) continue;
|
||||||
|
|
||||||
|
final centerIdx = cy * width + cx;
|
||||||
|
final centerPenalty = 0.85 + (gray[centerIdx] / 255.0) * 0.15;
|
||||||
|
final score = normalizedRing * centerPenalty;
|
||||||
|
|
||||||
|
if (score > bestScore) {
|
||||||
|
bestScore = score;
|
||||||
|
best = _DetectedCircle(
|
||||||
|
cx: cx.toDouble(), cy: cy.toDouble(), r: r.toDouble());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return bestScore >= 0.25 ? best : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
_DetectedCircle _fallbackCapCircle(int width, int height) {
|
||||||
|
return _DetectedCircle(
|
||||||
|
cx: width / 2,
|
||||||
|
cy: height / 2,
|
||||||
|
r: math.min(width, height) * 0.28,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
_CapColorStats _sampleCapColors(img.Image image, _DetectedCircle circle) {
|
||||||
final buckets = <int, _RgbBucket>{};
|
final buckets = <int, _RgbBucket>{};
|
||||||
double sumR = 0;
|
double sumR = 0;
|
||||||
double sumG = 0;
|
double sumG = 0;
|
||||||
double sumB = 0;
|
double sumB = 0;
|
||||||
int included = 0;
|
int included = 0;
|
||||||
|
|
||||||
for (int y = 0; y < analysisSize.height; y++) {
|
final insetRadius = circle.r * 0.78;
|
||||||
final dy = y - cy;
|
final r2 = insetRadius * insetRadius;
|
||||||
for (int x = 0; x < analysisSize.width; x++) {
|
final minX = math.max(0, (circle.cx - insetRadius).floor());
|
||||||
final dx = x - cx;
|
final maxX = math.min(image.width - 1, (circle.cx + insetRadius).ceil());
|
||||||
|
final minY = math.max(0, (circle.cy - insetRadius).floor());
|
||||||
|
final maxY = math.min(image.height - 1, (circle.cy + insetRadius).ceil());
|
||||||
|
|
||||||
|
for (int y = minY; y <= maxY; y++) {
|
||||||
|
final dy = y - circle.cy;
|
||||||
|
for (int x = minX; x <= maxX; x++) {
|
||||||
|
final dx = x - circle.cx;
|
||||||
if ((dx * dx) + (dy * dy) > r2) continue;
|
if ((dx * dx) + (dy * dy) > r2) continue;
|
||||||
final p = analysisSize.getPixel(x, y);
|
|
||||||
|
final p = image.getPixel(x, y);
|
||||||
final r = p.r.toInt();
|
final r = p.r.toInt();
|
||||||
final g = p.g.toInt();
|
final g = p.g.toInt();
|
||||||
final b = p.b.toInt();
|
final b = p.b.toInt();
|
||||||
@@ -1247,33 +1406,66 @@ Map<String, dynamic> _extractCapFromCenterIsolate(Uint8List sourceBytes) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
int resultArgb;
|
|
||||||
if (included == 0) {
|
if (included == 0) {
|
||||||
resultArgb = const Color(0xFFFF9800).toARGB32();
|
final fallback = const Color(0xFFFF9800).toARGB32();
|
||||||
} else {
|
return _CapColorStats(averageArgb: fallback, dominantArgb: fallback);
|
||||||
|
}
|
||||||
|
|
||||||
|
final average = Color.fromARGB(255, (sumR / included).round(),
|
||||||
|
(sumG / included).round(), (sumB / included).round())
|
||||||
|
.toARGB32();
|
||||||
|
|
||||||
_RgbBucket? dominant;
|
_RgbBucket? dominant;
|
||||||
for (final bucket in buckets.values) {
|
for (final bucket in buckets.values) {
|
||||||
if (dominant == null || bucket.count > dominant.count) dominant = bucket;
|
if (dominant == null || bucket.count > dominant.count) dominant = bucket;
|
||||||
}
|
}
|
||||||
|
final dominantArgb = dominant == null
|
||||||
if (dominant == null || dominant.count < included * 0.08) {
|
? average
|
||||||
resultArgb = Color.fromARGB(255, (sumR / included).round(),
|
: Color.fromARGB(
|
||||||
(sumG / included).round(), (sumB / included).round())
|
|
||||||
.toARGB32();
|
|
||||||
} else {
|
|
||||||
resultArgb = Color.fromARGB(
|
|
||||||
255,
|
255,
|
||||||
(dominant.r / dominant.count).round(),
|
(dominant.r / dominant.count).round(),
|
||||||
(dominant.g / dominant.count).round(),
|
(dominant.g / dominant.count).round(),
|
||||||
(dominant.b / dominant.count).round())
|
(dominant.b / dominant.count).round())
|
||||||
.toARGB32();
|
.toARGB32();
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
return _CapColorStats(averageArgb: average, dominantArgb: dominantArgb);
|
||||||
'color': resultArgb,
|
}
|
||||||
'previewPng': Uint8List.fromList(img.encodePng(centered, level: 1)),
|
|
||||||
};
|
void _drawCircle(img.Image image, double cx, double cy, double r) {
|
||||||
|
final x = cx.round();
|
||||||
|
final y = cy.round();
|
||||||
|
final radius = r.round();
|
||||||
|
for (int i = 0; i < 3; i++) {
|
||||||
|
img.drawCircle(
|
||||||
|
image,
|
||||||
|
x: x,
|
||||||
|
y: y,
|
||||||
|
radius: math.max(2, radius - i),
|
||||||
|
color: img.ColorRgb8(255, 255, 255),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
img.drawCircle(
|
||||||
|
image,
|
||||||
|
x: x,
|
||||||
|
y: y,
|
||||||
|
radius: math.max(2, radius - 3),
|
||||||
|
color: img.ColorRgb8(0, 140, 255),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
class _DetectedCircle {
|
||||||
|
final double cx;
|
||||||
|
final double cy;
|
||||||
|
final double r;
|
||||||
|
|
||||||
|
const _DetectedCircle({required this.cx, required this.cy, required this.r});
|
||||||
|
}
|
||||||
|
|
||||||
|
class _CapColorStats {
|
||||||
|
final int averageArgb;
|
||||||
|
final int dominantArgb;
|
||||||
|
|
||||||
|
const _CapColorStats({required this.averageArgb, required this.dominantArgb});
|
||||||
}
|
}
|
||||||
|
|
||||||
class _RgbBucket {
|
class _RgbBucket {
|
||||||
|
|||||||
Reference in New Issue
Block a user