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
|
||||
- Catalog management:
|
||||
- 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
|
||||
- edit existing entry name/color
|
||||
- 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:io';
|
||||
import 'dart:math' as math;
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
@@ -32,6 +31,8 @@ enum StylePreset { realistisch, ausgewogen, kuenstlerisch }
|
||||
|
||||
enum CatalogViewMode { list, grid }
|
||||
|
||||
enum ColorExtractionMode { dominant, average }
|
||||
|
||||
class MosaicHomePage extends StatefulWidget {
|
||||
const MosaicHomePage({super.key});
|
||||
|
||||
@@ -57,6 +58,7 @@ class _MosaicHomePageState extends State<MosaicHomePage> {
|
||||
int _generationToken = 0;
|
||||
bool _isCatalogLoaded = false;
|
||||
CatalogViewMode _catalogViewMode = CatalogViewMode.grid;
|
||||
ColorExtractionMode _colorExtractionMode = ColorExtractionMode.dominant;
|
||||
|
||||
double _fidelityStructure = 0.5;
|
||||
double _ditheringStrength = 0.35;
|
||||
@@ -154,11 +156,16 @@ class _MosaicHomePageState extends State<MosaicHomePage> {
|
||||
if (captured == null) return;
|
||||
|
||||
final bytes = await captured.readAsBytes();
|
||||
final detected = await compute(_extractCapFromCenterIsolate, bytes);
|
||||
final detected = await compute(_extractCapFromPhotoIsolate, bytes);
|
||||
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 bool usedFallback = detected['usedFallback'] as bool? ?? false;
|
||||
ColorExtractionMode mode = _colorExtractionMode;
|
||||
Color selected =
|
||||
mode == ColorExtractionMode.dominant ? dominantColor : averageColor;
|
||||
_photoCapNameCtrl.text = 'Fotografierter Deckel';
|
||||
_photoCapHexCtrl.text = _colorToHex(selected);
|
||||
|
||||
@@ -181,7 +188,36 @@ class _MosaicHomePageState extends State<MosaicHomePage> {
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
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,
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
@@ -235,12 +271,14 @@ class _MosaicHomePageState extends State<MosaicHomePage> {
|
||||
final parsed = _parseHex(_photoCapHexCtrl.text);
|
||||
final entry = CapCatalogEntry.newEntry(
|
||||
name: name, color: parsed ?? selected);
|
||||
_colorExtractionMode = mode;
|
||||
entry.imagePath =
|
||||
await _saveThumbnail(previewBytes, entry.id);
|
||||
_catalog.add(entry);
|
||||
await _persistCatalog();
|
||||
if (!mounted) return;
|
||||
setState(() {});
|
||||
if (!ctx.mounted) return;
|
||||
Navigator.pop(ctx);
|
||||
_scheduleRegenerate();
|
||||
},
|
||||
@@ -321,8 +359,9 @@ class _MosaicHomePageState extends State<MosaicHomePage> {
|
||||
const InputDecoration(labelText: 'Hex (#RRGGBB)'),
|
||||
onChanged: (value) {
|
||||
final parsed = _parseHex(value);
|
||||
if (parsed != null)
|
||||
if (parsed != null) {
|
||||
setDialogState(() => selected = parsed);
|
||||
}
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
@@ -369,6 +408,7 @@ class _MosaicHomePageState extends State<MosaicHomePage> {
|
||||
await _persistCatalog();
|
||||
if (!mounted) return;
|
||||
setState(() {});
|
||||
if (!ctx.mounted) return;
|
||||
Navigator.pop(ctx);
|
||||
_scheduleRegenerate();
|
||||
},
|
||||
@@ -433,6 +473,7 @@ class _MosaicHomePageState extends State<MosaicHomePage> {
|
||||
await _persistCatalog();
|
||||
if (!mounted) return;
|
||||
setState(() {});
|
||||
if (!ctx.mounted) return;
|
||||
Navigator.pop(ctx);
|
||||
_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);
|
||||
if (decoded == null) {
|
||||
return {
|
||||
'color': Colors.orange.toARGB32(),
|
||||
'dominantColor': Colors.orange.toARGB32(),
|
||||
'averageColor': Colors.orange.toARGB32(),
|
||||
'usedFallback': true,
|
||||
'previewPng': Uint8List.fromList(
|
||||
img.encodePng(img.Image(width: 1, height: 1), level: 1)),
|
||||
};
|
||||
}
|
||||
|
||||
final cropSize = math.min(decoded.width, decoded.height);
|
||||
final startX = (decoded.width - cropSize) ~/ 2;
|
||||
final startY = (decoded.height - cropSize) ~/ 2;
|
||||
final centered = img.copyCrop(decoded,
|
||||
x: startX, y: startY, width: cropSize, height: cropSize);
|
||||
const int analysisMaxSize = 480;
|
||||
final double scale = decoded.width >= decoded.height
|
||||
? (decoded.width > analysisMaxSize
|
||||
? analysisMaxSize / decoded.width
|
||||
: 1.0)
|
||||
: (decoded.height > analysisMaxSize
|
||||
? analysisMaxSize / decoded.height
|
||||
: 1.0);
|
||||
|
||||
final analysisSize = centered.width > 420
|
||||
? img.copyResize(centered, width: 420, height: 420)
|
||||
: centered;
|
||||
final analysis = scale < 1.0
|
||||
? img.copyResize(decoded,
|
||||
width: (decoded.width * scale).round(),
|
||||
height: (decoded.height * scale).round(),
|
||||
interpolation: img.Interpolation.average)
|
||||
: decoded;
|
||||
|
||||
final cx = analysisSize.width / 2;
|
||||
final cy = analysisSize.height / 2;
|
||||
final radius = math.min(analysisSize.width, analysisSize.height) * 0.30;
|
||||
final r2 = radius * radius;
|
||||
final detectedMaybe = _detectCapCircle(analysis);
|
||||
final detected =
|
||||
detectedMaybe ?? _fallbackCapCircle(analysis.width, analysis.height);
|
||||
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>{};
|
||||
double sumR = 0;
|
||||
double sumG = 0;
|
||||
double sumB = 0;
|
||||
int included = 0;
|
||||
|
||||
for (int y = 0; y < analysisSize.height; y++) {
|
||||
final dy = y - cy;
|
||||
for (int x = 0; x < analysisSize.width; x++) {
|
||||
final dx = x - cx;
|
||||
final insetRadius = circle.r * 0.78;
|
||||
final r2 = insetRadius * insetRadius;
|
||||
final minX = math.max(0, (circle.cx - insetRadius).floor());
|
||||
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;
|
||||
final p = analysisSize.getPixel(x, y);
|
||||
|
||||
final p = image.getPixel(x, y);
|
||||
final r = p.r.toInt();
|
||||
final g = p.g.toInt();
|
||||
final b = p.b.toInt();
|
||||
@@ -1247,33 +1406,66 @@ Map<String, dynamic> _extractCapFromCenterIsolate(Uint8List sourceBytes) {
|
||||
}
|
||||
}
|
||||
|
||||
int resultArgb;
|
||||
if (included == 0) {
|
||||
resultArgb = const Color(0xFFFF9800).toARGB32();
|
||||
} else {
|
||||
final fallback = const Color(0xFFFF9800).toARGB32();
|
||||
return _CapColorStats(averageArgb: fallback, dominantArgb: fallback);
|
||||
}
|
||||
|
||||
final average = Color.fromARGB(255, (sumR / included).round(),
|
||||
(sumG / included).round(), (sumB / included).round())
|
||||
.toARGB32();
|
||||
|
||||
_RgbBucket? dominant;
|
||||
for (final bucket in buckets.values) {
|
||||
if (dominant == null || bucket.count > dominant.count) dominant = bucket;
|
||||
}
|
||||
|
||||
if (dominant == null || dominant.count < included * 0.08) {
|
||||
resultArgb = Color.fromARGB(255, (sumR / included).round(),
|
||||
(sumG / included).round(), (sumB / included).round())
|
||||
.toARGB32();
|
||||
} else {
|
||||
resultArgb = Color.fromARGB(
|
||||
final dominantArgb = dominant == null
|
||||
? average
|
||||
: Color.fromARGB(
|
||||
255,
|
||||
(dominant.r / dominant.count).round(),
|
||||
(dominant.g / dominant.count).round(),
|
||||
(dominant.b / dominant.count).round())
|
||||
.toARGB32();
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
'color': resultArgb,
|
||||
'previewPng': Uint8List.fromList(img.encodePng(centered, level: 1)),
|
||||
};
|
||||
return _CapColorStats(averageArgb: average, dominantArgb: dominantArgb);
|
||||
}
|
||||
|
||||
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 {
|
||||
|
||||
Reference in New Issue
Block a user