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 {
final XFile? captured = await _picker.pickImage(
source: ImageSource.camera,
preferredCameraDevice: CameraDevice.rear,
imageQuality: 95,
maxWidth: 1800,
);
@@ -159,10 +160,17 @@ class _MosaicHomePageState extends State<MosaicHomePage> {
final detected = await compute(_extractCapFromPhotoIsolate, bytes);
if (!mounted) return;
final dominantColor = Color(detected['dominantColor'] as int);
final averageColor = Color(detected['averageColor'] as int);
final previewBytes = detected['previewPng'] as Uint8List;
Color dominantColor = Color(detected['dominantColor'] as int);
Color averageColor = Color(detected['averageColor'] as int);
Uint8List previewBytes = detected['previewPng'] as Uint8List;
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;
Color selected =
mode == ColorExtractionMode.dominant ? dominantColor : averageColor;
@@ -174,6 +182,26 @@ class _MosaicHomePageState extends State<MosaicHomePage> {
builder: (ctx) {
return StatefulBuilder(
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(
title: const Text('Deckel fotografieren'),
content: SingleChildScrollView(
@@ -189,8 +217,8 @@ class _MosaicHomePageState extends State<MosaicHomePage> {
const SizedBox(height: 8),
Text(
usedFallback
? 'Kreis wurde per Kanten-Fallback erkannt.'
: 'Kreis wurde automatisch auf dem Deckel erkannt.',
? 'Kreiserkennung per Fallback. Bei Bedarf Kreis manuell korrigieren.'
: 'Kreis automatisch erkannt. Optional unten feinjustieren.',
style: Theme.of(context).textTheme.bodySmall,
),
const SizedBox(height: 8),
@@ -198,11 +226,11 @@ class _MosaicHomePageState extends State<MosaicHomePage> {
segments: const [
ButtonSegment(
value: ColorExtractionMode.dominant,
label: Text('Dominante Farbe'),
label: Text('Dominant (robust)'),
),
ButtonSegment(
value: ColorExtractionMode.average,
label: Text('Durchschnitt'),
label: Text('Gewichteter Mittelwert'),
),
],
selected: {mode},
@@ -217,10 +245,42 @@ class _MosaicHomePageState extends State<MosaicHomePage> {
),
const SizedBox(height: 8),
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,
),
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(
children: [
Container(
@@ -1243,6 +1303,11 @@ Map<String, dynamic> _extractCapFromPhotoIsolate(Uint8List sourceBytes) {
'dominantColor': Colors.orange.toARGB32(),
'averageColor': Colors.orange.toARGB32(),
'usedFallback': true,
'circleX': 0.5,
'circleY': 0.5,
'circleR': 0.3,
'imageW': 1,
'imageH': 1,
'previewPng': Uint8List.fromList(
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 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,
width: math.min(analysis.width, 320),
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;
_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)),
};
return preview;
}
_DetectedCircle? _detectCapCircle(img.Image image) {
@@ -1371,13 +1497,9 @@ _DetectedCircle _fallbackCapCircle(int width, int height) {
}
_CapColorStats _sampleCapColors(img.Image image, _DetectedCircle circle) {
final buckets = <int, _RgbBucket>{};
double sumR = 0;
double sumG = 0;
double sumB = 0;
int included = 0;
final samples = <_ColorSample>[];
final insetRadius = circle.r * 0.78;
final insetRadius = circle.r * 0.76;
final r2 = insetRadius * insetRadius;
final minX = math.max(0, (circle.cx - insetRadius).floor());
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;
for (int x = minX; x <= maxX; x++) {
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 r = p.r.toInt();
final g = p.g.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;
sumG += g;
sumB += b;
included++;
final bucketKey = ((r ~/ 16) << 8) | ((g ~/ 16) << 4) | (b ~/ 16);
final bucket = buckets.putIfAbsent(bucketKey, () => _RgbBucket());
bucket.add(r, g, b);
samples.add(_ColorSample(
r: r,
g: g,
b: b,
saturation: hsv[1],
value: hsv[2],
luminance: lum,
radialWeight: radial.clamp(0.1, 1.0),
));
}
}
if (included == 0) {
if (samples.isEmpty) {
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();
final lumValues = samples.map((s) => s.luminance).toList()..sort();
final lowLum = _percentile(lumValues, 0.18);
final highLum = _percentile(lumValues, 0.98);
_RgbBucket? dominant;
for (final bucket in buckets.values) {
if (dominant == null || bucket.count > dominant.count) dominant = bucket;
}
final dominantArgb = dominant == null
? average
: Color.fromARGB(
255,
(dominant.r / dominant.count).round(),
(dominant.g / dominant.count).round(),
(dominant.b / dominant.count).round())
.toARGB32();
final filtered = samples.where((s) {
if (s.luminance < lowLum || s.luminance > highLum) return false;
if (s.value < 0.18) return false;
return true;
}).toList();
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) {
@@ -1468,17 +1593,39 @@ class _CapColorStats {
const _CapColorStats({required this.averageArgb, required this.dominantArgb});
}
class _RgbBucket {
int count = 0;
int r = 0;
int g = 0;
int b = 0;
class _ColorSample {
final int r;
final int g;
final int b;
final double saturation;
final double value;
final double luminance;
final double radialWeight;
void add(int nr, int ng, int nb) {
count++;
r += nr;
g += ng;
b += nb;
const _ColorSample({
required this.r,
required this.g,
required this.b,
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});
}
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;
List<double> _argbToLab(int argb) {