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 {
|
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) {
|
||||||
|
|||||||
Reference in New Issue
Block a user