Fix rear camera default and robust cap color correction workflow

This commit is contained in:
gary
2026-02-21 21:03:38 +01:00
parent 448d9dc649
commit b82d9a03e8

View File

@@ -150,6 +150,7 @@ class _MosaicHomePageState extends State<MosaicHomePage> {
Future<void> _captureCapPhoto() async { Future<void> _captureCapPhoto() async {
final XFile? captured = await _picker.pickImage( final XFile? captured = await _picker.pickImage(
source: ImageSource.camera, source: ImageSource.camera,
preferredCameraDevice: CameraDevice.rear,
imageQuality: 95, imageQuality: 95,
maxWidth: 1800, maxWidth: 1800,
); );
@@ -159,10 +160,17 @@ class _MosaicHomePageState extends State<MosaicHomePage> {
final detected = await compute(_extractCapFromPhotoIsolate, bytes); final detected = await compute(_extractCapFromPhotoIsolate, bytes);
if (!mounted) return; if (!mounted) return;
final dominantColor = Color(detected['dominantColor'] as int); Color dominantColor = Color(detected['dominantColor'] as int);
final averageColor = Color(detected['averageColor'] as int); Color averageColor = Color(detected['averageColor'] as int);
final previewBytes = detected['previewPng'] as Uint8List; Uint8List previewBytes = detected['previewPng'] as Uint8List;
final bool usedFallback = detected['usedFallback'] as bool? ?? false; final bool usedFallback = detected['usedFallback'] as bool? ?? false;
final imageW = (detected['imageW'] as num).toDouble();
final imageH = (detected['imageH'] as num).toDouble();
double circleX = (detected['circleX'] as num).toDouble() / imageW;
double circleY = (detected['circleY'] as num).toDouble() / imageH;
double circleR =
(detected['circleR'] as num).toDouble() / math.min(imageW, imageH);
ColorExtractionMode mode = _colorExtractionMode; ColorExtractionMode mode = _colorExtractionMode;
Color selected = Color selected =
mode == ColorExtractionMode.dominant ? dominantColor : averageColor; mode == ColorExtractionMode.dominant ? dominantColor : averageColor;
@@ -174,6 +182,26 @@ class _MosaicHomePageState extends State<MosaicHomePage> {
builder: (ctx) { builder: (ctx) {
return StatefulBuilder( return StatefulBuilder(
builder: (ctx, setDialogState) { builder: (ctx, setDialogState) {
Future<void> recalculate() async {
final adjusted = await compute(
_extractCapFromAdjustedCircleIsolate,
<String, dynamic>{
'sourceBytes': bytes,
'circleX': circleX,
'circleY': circleY,
'circleR': circleR,
},
);
dominantColor = Color(adjusted['dominantColor'] as int);
averageColor = Color(adjusted['averageColor'] as int);
previewBytes = adjusted['previewPng'] as Uint8List;
selected = mode == ColorExtractionMode.dominant
? dominantColor
: averageColor;
_photoCapHexCtrl.text = _colorToHex(selected);
setDialogState(() {});
}
return AlertDialog( return AlertDialog(
title: const Text('Deckel fotografieren'), title: const Text('Deckel fotografieren'),
content: SingleChildScrollView( content: SingleChildScrollView(
@@ -189,8 +217,8 @@ class _MosaicHomePageState extends State<MosaicHomePage> {
const SizedBox(height: 8), const SizedBox(height: 8),
Text( Text(
usedFallback usedFallback
? 'Kreis wurde per Kanten-Fallback erkannt.' ? 'Kreiserkennung per Fallback. Bei Bedarf Kreis manuell korrigieren.'
: 'Kreis wurde automatisch auf dem Deckel erkannt.', : 'Kreis automatisch erkannt. Optional unten feinjustieren.',
style: Theme.of(context).textTheme.bodySmall, style: Theme.of(context).textTheme.bodySmall,
), ),
const SizedBox(height: 8), const SizedBox(height: 8),
@@ -198,11 +226,11 @@ class _MosaicHomePageState extends State<MosaicHomePage> {
segments: const [ segments: const [
ButtonSegment( ButtonSegment(
value: ColorExtractionMode.dominant, value: ColorExtractionMode.dominant,
label: Text('Dominante Farbe'), label: Text('Dominant (robust)'),
), ),
ButtonSegment( ButtonSegment(
value: ColorExtractionMode.average, value: ColorExtractionMode.average,
label: Text('Durchschnitt'), label: Text('Gewichteter Mittelwert'),
), ),
], ],
selected: {mode}, selected: {mode},
@@ -217,10 +245,42 @@ class _MosaicHomePageState extends State<MosaicHomePage> {
), ),
const SizedBox(height: 8), const SizedBox(height: 8),
Text( Text(
'Nur Pixel innerhalb des erkannten Kreisinneren werden ausgewertet.', 'Robuste Analyse: Schatten/Outlier werden reduziert, nur geeignete Pixel im Kreis werden gewichtet.',
style: Theme.of(context).textTheme.bodySmall, style: Theme.of(context).textTheme.bodySmall,
), ),
const SizedBox(height: 12), const SizedBox(height: 8),
const Text('Manuelle Kreis-Korrektur',
style: TextStyle(fontWeight: FontWeight.w600)),
Slider(
value: circleX.clamp(0.15, 0.85),
min: 0.15,
max: 0.85,
label: 'X',
onChanged: (v) => setDialogState(() => circleX = v),
),
Slider(
value: circleY.clamp(0.15, 0.85),
min: 0.15,
max: 0.85,
label: 'Y',
onChanged: (v) => setDialogState(() => circleY = v),
),
Slider(
value: circleR.clamp(0.12, 0.48),
min: 0.12,
max: 0.48,
label: 'Radius',
onChanged: (v) => setDialogState(() => circleR = v),
),
Align(
alignment: Alignment.centerRight,
child: OutlinedButton.icon(
onPressed: recalculate,
icon: const Icon(Icons.tune),
label: const Text('Kreis anwenden & neu berechnen'),
),
),
const SizedBox(height: 8),
Row( Row(
children: [ children: [
Container( Container(
@@ -1243,6 +1303,11 @@ Map<String, dynamic> _extractCapFromPhotoIsolate(Uint8List sourceBytes) {
'dominantColor': Colors.orange.toARGB32(), 'dominantColor': Colors.orange.toARGB32(),
'averageColor': Colors.orange.toARGB32(), 'averageColor': Colors.orange.toARGB32(),
'usedFallback': true, 'usedFallback': true,
'circleX': 0.5,
'circleY': 0.5,
'circleR': 0.3,
'imageW': 1,
'imageH': 1,
'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)),
}; };
@@ -1276,7 +1341,74 @@ Map<String, dynamic> _extractCapFromPhotoIsolate(Uint8List sourceBytes) {
); );
final stats = _sampleCapColors(decoded, upscaledCircle); final stats = _sampleCapColors(decoded, upscaledCircle);
final preview = _buildCirclePreview(analysis, detected);
return {
'dominantColor': stats.dominantArgb,
'averageColor': stats.averageArgb,
'usedFallback': usedFallback,
'circleX': upscaledCircle.cx,
'circleY': upscaledCircle.cy,
'circleR': upscaledCircle.r,
'imageW': decoded.width,
'imageH': decoded.height,
'previewPng': Uint8List.fromList(img.encodePng(preview, level: 1)),
};
}
Map<String, dynamic> _extractCapFromAdjustedCircleIsolate(
Map<String, dynamic> request) {
final sourceBytes = request['sourceBytes'] as Uint8List;
final decoded = img.decodeImage(sourceBytes);
if (decoded == null) {
return {
'dominantColor': Colors.orange.toARGB32(),
'averageColor': Colors.orange.toARGB32(),
'previewPng': Uint8List.fromList(
img.encodePng(img.Image(width: 1, height: 1), level: 1)),
};
}
final cx = ((request['circleX'] as num).toDouble() * decoded.width)
.clamp(0.0, decoded.width.toDouble());
final cy = ((request['circleY'] as num).toDouble() * decoded.height)
.clamp(0.0, decoded.height.toDouble());
final r = ((request['circleR'] as num).toDouble() *
math.min(decoded.width, decoded.height))
.clamp(8.0, math.min(decoded.width, decoded.height) * 0.49);
final circle = _DetectedCircle(cx: cx, cy: cy, r: r);
final stats = _sampleCapColors(decoded, circle);
const int analysisMaxSize = 480;
final scale = decoded.width >= decoded.height
? (decoded.width > analysisMaxSize
? analysisMaxSize / decoded.width
: 1.0)
: (decoded.height > analysisMaxSize
? analysisMaxSize / decoded.height
: 1.0);
final analysis = scale < 1.0
? img.copyResize(decoded,
width: (decoded.width * scale).round(),
height: (decoded.height * scale).round(),
interpolation: img.Interpolation.average)
: decoded;
final preview = _buildCirclePreview(
analysis,
_DetectedCircle(
cx: circle.cx * scale, cy: circle.cy * scale, r: circle.r * scale),
);
return {
'dominantColor': stats.dominantArgb,
'averageColor': stats.averageArgb,
'previewPng': Uint8List.fromList(img.encodePng(preview, level: 1)),
};
}
img.Image _buildCirclePreview(img.Image analysis, _DetectedCircle detected) {
final preview = img.copyResize(analysis, final preview = img.copyResize(analysis,
width: math.min(analysis.width, 320), width: math.min(analysis.width, 320),
height: (math.min(analysis.width, 320) * analysis.height / analysis.width) height: (math.min(analysis.width, 320) * analysis.height / analysis.width)
@@ -1284,13 +1416,7 @@ Map<String, dynamic> _extractCapFromPhotoIsolate(Uint8List sourceBytes) {
final previewScale = preview.width / analysis.width; final previewScale = preview.width / analysis.width;
_drawCircle(preview, detected.cx * previewScale, detected.cy * previewScale, _drawCircle(preview, detected.cx * previewScale, detected.cy * previewScale,
detected.r * previewScale); detected.r * previewScale);
return preview;
return {
'dominantColor': stats.dominantArgb,
'averageColor': stats.averageArgb,
'usedFallback': usedFallback,
'previewPng': Uint8List.fromList(img.encodePng(preview, level: 1)),
};
} }
_DetectedCircle? _detectCapCircle(img.Image image) { _DetectedCircle? _detectCapCircle(img.Image image) {
@@ -1371,13 +1497,9 @@ _DetectedCircle _fallbackCapCircle(int width, int height) {
} }
_CapColorStats _sampleCapColors(img.Image image, _DetectedCircle circle) { _CapColorStats _sampleCapColors(img.Image image, _DetectedCircle circle) {
final buckets = <int, _RgbBucket>{}; final samples = <_ColorSample>[];
double sumR = 0;
double sumG = 0;
double sumB = 0;
int included = 0;
final insetRadius = circle.r * 0.78; final insetRadius = circle.r * 0.76;
final r2 = insetRadius * insetRadius; final r2 = insetRadius * insetRadius;
final minX = math.max(0, (circle.cx - insetRadius).floor()); final minX = math.max(0, (circle.cx - insetRadius).floor());
final maxX = math.min(image.width - 1, (circle.cx + insetRadius).ceil()); final maxX = math.min(image.width - 1, (circle.cx + insetRadius).ceil());
@@ -1388,47 +1510,50 @@ _CapColorStats _sampleCapColors(img.Image image, _DetectedCircle circle) {
final dy = y - circle.cy; final dy = y - circle.cy;
for (int x = minX; x <= maxX; x++) { for (int x = minX; x <= maxX; x++) {
final dx = x - circle.cx; final dx = x - circle.cx;
if ((dx * dx) + (dy * dy) > r2) continue; final dist2 = (dx * dx) + (dy * dy);
if (dist2 > r2) continue;
final p = image.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();
final hsv = _rgbToHsv(r, g, b);
final lum = 0.2126 * r + 0.7152 * g + 0.0722 * b;
final radial = 1.0 - math.sqrt(dist2) / insetRadius;
sumR += r; samples.add(_ColorSample(
sumG += g; r: r,
sumB += b; g: g,
included++; b: b,
saturation: hsv[1],
final bucketKey = ((r ~/ 16) << 8) | ((g ~/ 16) << 4) | (b ~/ 16); value: hsv[2],
final bucket = buckets.putIfAbsent(bucketKey, () => _RgbBucket()); luminance: lum,
bucket.add(r, g, b); radialWeight: radial.clamp(0.1, 1.0),
));
} }
} }
if (included == 0) { if (samples.isEmpty) {
final fallback = const Color(0xFFFF9800).toARGB32(); final fallback = const Color(0xFFFF9800).toARGB32();
return _CapColorStats(averageArgb: fallback, dominantArgb: fallback); return _CapColorStats(averageArgb: fallback, dominantArgb: fallback);
} }
final average = Color.fromARGB(255, (sumR / included).round(), final lumValues = samples.map((s) => s.luminance).toList()..sort();
(sumG / included).round(), (sumB / included).round()) final lowLum = _percentile(lumValues, 0.18);
.toARGB32(); final highLum = _percentile(lumValues, 0.98);
_RgbBucket? dominant; final filtered = samples.where((s) {
for (final bucket in buckets.values) { if (s.luminance < lowLum || s.luminance > highLum) return false;
if (dominant == null || bucket.count > dominant.count) dominant = bucket; if (s.value < 0.18) return false;
} return true;
final dominantArgb = dominant == null }).toList();
? average
: Color.fromARGB(
255,
(dominant.r / dominant.count).round(),
(dominant.g / dominant.count).round(),
(dominant.b / dominant.count).round())
.toARGB32();
return _CapColorStats(averageArgb: average, dominantArgb: dominantArgb); final usable = filtered.isEmpty ? samples : filtered;
final avg = _weightedAverage(usable);
final dominant = _weightedDominant(usable);
return _CapColorStats(averageArgb: avg, dominantArgb: dominant);
} }
void _drawCircle(img.Image image, double cx, double cy, double r) { void _drawCircle(img.Image image, double cx, double cy, double r) {
@@ -1468,17 +1593,39 @@ class _CapColorStats {
const _CapColorStats({required this.averageArgb, required this.dominantArgb}); const _CapColorStats({required this.averageArgb, required this.dominantArgb});
} }
class _RgbBucket { class _ColorSample {
int count = 0; final int r;
int r = 0; final int g;
int g = 0; final int b;
int b = 0; final double saturation;
final double value;
final double luminance;
final double radialWeight;
void add(int nr, int ng, int nb) { const _ColorSample({
count++; required this.r,
r += nr; required this.g,
g += ng; required this.b,
b += nb; required this.saturation,
required this.value,
required this.luminance,
required this.radialWeight,
});
double get weight => radialWeight * (0.55 + saturation * 0.75);
}
class _WeightedRgb {
double weight = 0;
double r = 0;
double g = 0;
double b = 0;
void add(_ColorSample s, double w) {
weight += w;
r += s.r * w;
g += s.g * w;
b += s.b * w;
} }
} }
@@ -1489,6 +1636,82 @@ class _Candidate {
const _Candidate({required this.index, required this.distance}); const _Candidate({required this.index, required this.distance});
} }
int _weightedAverage(List<_ColorSample> samples) {
final acc = _WeightedRgb();
for (final s in samples) {
final w = s.weight;
acc.add(s, w);
}
if (acc.weight <= 0) return const Color(0xFFFF9800).toARGB32();
return Color.fromARGB(
255,
(acc.r / acc.weight).round().clamp(0, 255),
(acc.g / acc.weight).round().clamp(0, 255),
(acc.b / acc.weight).round().clamp(0, 255),
).toARGB32();
}
int _weightedDominant(List<_ColorSample> samples) {
final bins = <int, _WeightedRgb>{};
for (final s in samples) {
final hsv = _rgbToHsv(s.r, s.g, s.b);
final hBin = (hsv[0] / 20).floor().clamp(0, 17);
final sBin = (hsv[1] * 4).floor().clamp(0, 3);
final vBin = (hsv[2] * 4).floor().clamp(0, 3);
final key = (hBin << 6) | (sBin << 3) | vBin;
final bucket = bins.putIfAbsent(key, () => _WeightedRgb());
bucket.add(s, s.weight);
}
_WeightedRgb? best;
for (final b in bins.values) {
if (best == null || b.weight > best.weight) best = b;
}
if (best == null || best.weight <= 0) return _weightedAverage(samples);
return Color.fromARGB(
255,
(best.r / best.weight).round().clamp(0, 255),
(best.g / best.weight).round().clamp(0, 255),
(best.b / best.weight).round().clamp(0, 255),
).toARGB32();
}
List<double> _rgbToHsv(int r, int g, int b) {
final rf = r / 255.0;
final gf = g / 255.0;
final bf = b / 255.0;
final maxC = math.max(rf, math.max(gf, bf));
final minC = math.min(rf, math.min(gf, bf));
final delta = maxC - minC;
double h;
if (delta == 0) {
h = 0;
} else if (maxC == rf) {
h = 60 * (((gf - bf) / delta) % 6);
} else if (maxC == gf) {
h = 60 * (((bf - rf) / delta) + 2);
} else {
h = 60 * (((rf - gf) / delta) + 4);
}
if (h < 0) h += 360;
final s = maxC == 0 ? 0.0 : delta / maxC;
final v = maxC;
return [h, s, v];
}
double _percentile(List<double> sorted, double p) {
if (sorted.isEmpty) return 0;
final pos = (sorted.length - 1) * p.clamp(0.0, 1.0);
final lo = pos.floor();
final hi = pos.ceil();
if (lo == hi) return sorted[lo];
final t = pos - lo;
return sorted[lo] * (1 - t) + sorted[hi] * t;
}
double _mix(double a, double b, double t) => a + (b - a) * t; double _mix(double a, double b, double t) => a + (b - a) * t;
List<double> _argbToLab(int argb) { List<double> _argbToLab(int argb) {