Fix rear camera default and robust cap color correction workflow
This commit is contained in:
339
lib/main.dart
339
lib/main.dart
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user