Improve cap photo color extraction with circle masking

This commit is contained in:
gary
2026-02-21 20:54:34 +01:00
parent eb37322809
commit 448d9dc649
2 changed files with 237 additions and 44 deletions

View File

@@ -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)

View File

@@ -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 _CapColorStats(averageArgb: average, dominantArgb: dominantArgb);
} }
return { void _drawCircle(img.Image image, double cx, double cy, double r) {
'color': resultArgb, final x = cx.round();
'previewPng': Uint8List.fromList(img.encodePng(centered, level: 1)), 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 {