Add interactive manual circle drag/pinch with live color preview

This commit is contained in:
gary
2026-02-21 22:46:35 +01:00
parent b82d9a03e8
commit e392b99bef

View File

@@ -177,12 +177,30 @@ class _MosaicHomePageState extends State<MosaicHomePage> {
_photoCapNameCtrl.text = 'Fotografierter Deckel';
_photoCapHexCtrl.text = _colorToHex(selected);
showDialog<void>(
Timer? liveDebounce;
int recalcToken = 0;
final initialCircle = _clampNormalizedCircleToImage(
imageW: imageW,
imageH: imageH,
circleX: circleX,
circleY: circleY,
circleR: circleR,
);
circleX = initialCircle.x;
circleY = initialCircle.y;
circleR = initialCircle.r;
await showDialog<void>(
context: context,
builder: (ctx) {
return StatefulBuilder(
builder: (ctx, setDialogState) {
Future<void> recalculate() async {
Future<void> recalculate({bool updateDialog = true}) async {
final localToken = ++recalcToken;
if (updateDialog) {
setDialogState(() {});
}
final adjusted = await compute(
_extractCapFromAdjustedCircleIsolate,
<String, dynamic>{
@@ -192,6 +210,7 @@ class _MosaicHomePageState extends State<MosaicHomePage> {
'circleR': circleR,
},
);
if (localToken != recalcToken) return;
dominantColor = Color(adjusted['dominantColor'] as int);
averageColor = Color(adjusted['averageColor'] as int);
previewBytes = adjusted['previewPng'] as Uint8List;
@@ -199,8 +218,33 @@ class _MosaicHomePageState extends State<MosaicHomePage> {
? dominantColor
: averageColor;
_photoCapHexCtrl.text = _colorToHex(selected);
if (ctx.mounted) {
setDialogState(() {});
}
}
void scheduleLiveRecalculate() {
liveDebounce?.cancel();
liveDebounce = Timer(const Duration(milliseconds: 180), () {
recalculate(updateDialog: false);
});
}
void setAndClampCircle({double? x, double? y, double? r}) {
if (x != null) circleX = x;
if (y != null) circleY = y;
if (r != null) circleR = r;
final clamped = _clampNormalizedCircleToImage(
imageW: imageW,
imageH: imageH,
circleX: circleX,
circleY: circleY,
circleR: circleR,
);
circleX = clamped.x;
circleY = clamped.y;
circleR = clamped.r;
}
return AlertDialog(
title: const Text('Deckel fotografieren'),
@@ -209,16 +253,25 @@ class _MosaicHomePageState extends State<MosaicHomePage> {
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
ClipRRect(
borderRadius: BorderRadius.circular(12),
child: Image.memory(previewBytes,
width: 220, height: 220, fit: BoxFit.cover),
_CircleAdjustOverlay(
imageBytes: bytes,
imageWidth: imageW,
imageHeight: imageH,
circleX: circleX,
circleY: circleY,
circleR: circleR,
onCircleChanged: (x, y, r) {
setDialogState(() {
setAndClampCircle(x: x, y: y, r: r);
});
scheduleLiveRecalculate();
},
),
const SizedBox(height: 8),
Text(
usedFallback
? 'Kreiserkennung per Fallback. Bei Bedarf Kreis manuell korrigieren.'
: 'Kreis automatisch erkannt. Optional unten feinjustieren.',
? 'Kreiserkennung per Fallback. Kreis direkt im Foto verschieben/zoomen.'
: 'Kreis erkannt. Direkt im Foto ziehen oder mit Pinch/Slider anpassen.',
style: Theme.of(context).textTheme.bodySmall,
),
const SizedBox(height: 8),
@@ -243,42 +296,15 @@ class _MosaicHomePageState extends State<MosaicHomePage> {
setDialogState(() {});
},
),
const SizedBox(height: 8),
Text(
'Robuste Analyse: Schatten/Outlier werden reduziert, nur geeignete Pixel im Kreis werden gewichtet.',
style: Theme.of(context).textTheme.bodySmall,
),
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,
value: circleR,
min: 0.08,
max: 0.49,
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'),
),
onChanged: (v) {
setDialogState(() => setAndClampCircle(r: v));
scheduleLiveRecalculate();
},
),
const SizedBox(height: 8),
Row(
@@ -350,6 +376,24 @@ class _MosaicHomePageState extends State<MosaicHomePage> {
);
},
);
liveDebounce?.cancel();
}
_NormalizedCircle _clampNormalizedCircleToImage({
required double imageW,
required double imageH,
required double circleX,
required double circleY,
required double circleR,
}) {
return _clampCircleNormalized(
imageW: imageW,
imageH: imageH,
circleX: circleX,
circleY: circleY,
circleR: circleR,
);
}
void _scheduleRegenerate() {
@@ -996,6 +1040,130 @@ class _SliderRow extends StatelessWidget {
}
}
class _NormalizedCircle {
final double x;
final double y;
final double r;
const _NormalizedCircle({required this.x, required this.y, required this.r});
}
class _CircleAdjustOverlay extends StatefulWidget {
final Uint8List imageBytes;
final double imageWidth;
final double imageHeight;
final double circleX;
final double circleY;
final double circleR;
final void Function(double x, double y, double r) onCircleChanged;
const _CircleAdjustOverlay({
required this.imageBytes,
required this.imageWidth,
required this.imageHeight,
required this.circleX,
required this.circleY,
required this.circleR,
required this.onCircleChanged,
});
@override
State<_CircleAdjustOverlay> createState() => _CircleAdjustOverlayState();
}
class _CircleAdjustOverlayState extends State<_CircleAdjustOverlay> {
double? _baseRadius;
@override
Widget build(BuildContext context) {
return LayoutBuilder(builder: (context, constraints) {
final maxW = math.min(320.0, constraints.maxWidth);
final shownH = maxW * widget.imageHeight / widget.imageWidth;
return Center(
child: ClipRRect(
borderRadius: BorderRadius.circular(12),
child: SizedBox(
width: maxW,
height: shownH,
child: GestureDetector(
behavior: HitTestBehavior.opaque,
onScaleStart: (_) => _baseRadius = widget.circleR,
onScaleUpdate: (details) {
final local = details.localFocalPoint;
final x = (local.dx / maxW).clamp(0.0, 1.0);
final y = (local.dy / shownH).clamp(0.0, 1.0);
final scale = details.scale;
final r = ((_baseRadius ?? widget.circleR) * scale)
.clamp(0.06, 0.49);
widget.onCircleChanged(x, y, r);
},
onScaleEnd: (_) => _baseRadius = null,
child: Stack(
fit: StackFit.expand,
children: [
Image.memory(widget.imageBytes, fit: BoxFit.cover),
CustomPaint(
painter: _CircleOverlayPainter(
x: widget.circleX,
y: widget.circleY,
r: widget.circleR,
),
),
],
),
),
),
),
);
});
}
}
class _CircleOverlayPainter extends CustomPainter {
final double x;
final double y;
final double r;
const _CircleOverlayPainter({required this.x, required this.y, required this.r});
@override
void paint(Canvas canvas, Size size) {
final center = Offset(x * size.width, y * size.height);
final radius = r * math.min(size.width, size.height);
final overlayPaint = Paint()..color = Colors.black.withValues(alpha: 0.35);
final overlayPath = Path()..addRect(Offset.zero & size);
overlayPath.addOval(Rect.fromCircle(center: center, radius: radius));
overlayPath.fillType = PathFillType.evenOdd;
canvas.drawPath(overlayPath, overlayPaint);
final ringPaint = Paint()
..style = PaintingStyle.stroke
..strokeWidth = 2.5
..color = Colors.white;
canvas.drawCircle(center, radius, ringPaint);
final ringPaint2 = Paint()
..style = PaintingStyle.stroke
..strokeWidth = 1.2
..color = const Color(0xFF2196F3);
canvas.drawCircle(center, radius - 3, ringPaint2);
final cross = Paint()
..style = PaintingStyle.stroke
..strokeWidth = 1
..color = Colors.white70;
canvas.drawLine(Offset(center.dx - 10, center.dy), Offset(center.dx + 10, center.dy), cross);
canvas.drawLine(Offset(center.dx, center.dy - 10), Offset(center.dx, center.dy + 10), cross);
}
@override
bool shouldRepaint(covariant _CircleOverlayPainter oldDelegate) {
return oldDelegate.x != x || oldDelegate.y != y || oldDelegate.r != r;
}
}
class CapCatalogEntry {
final String id;
String name;
@@ -1369,15 +1537,20 @@ Map<String, dynamic> _extractCapFromAdjustedCircleIsolate(
};
}
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 minSide = math.min(decoded.width, decoded.height).toDouble();
final normalized = _clampCircleNormalized(
imageW: decoded.width.toDouble(),
imageH: decoded.height.toDouble(),
circleX: (request['circleX'] as num).toDouble(),
circleY: (request['circleY'] as num).toDouble(),
circleR: (request['circleR'] as num).toDouble(),
);
final circle = _DetectedCircle(cx: cx, cy: cy, r: r);
final circle = _DetectedCircle(
cx: normalized.x * decoded.width,
cy: normalized.y * decoded.height,
r: normalized.r * minSide,
);
final stats = _sampleCapColors(decoded, circle);
const int analysisMaxSize = 480;
@@ -1408,6 +1581,24 @@ Map<String, dynamic> _extractCapFromAdjustedCircleIsolate(
};
}
_NormalizedCircle _clampCircleNormalized({
required double imageW,
required double imageH,
required double circleX,
required double circleY,
required double circleR,
}) {
final minSide = math.min(imageW, imageH);
final r = circleR.clamp(8 / minSide, 0.49);
final minX = r * (minSide / imageW);
final minY = r * (minSide / imageH);
return _NormalizedCircle(
x: circleX.clamp(minX, 1 - minX),
y: circleY.clamp(minY, 1 - minY),
r: r,
);
}
img.Image _buildCirclePreview(img.Image analysis, _DetectedCircle detected) {
final preview = img.copyResize(analysis,
width: math.min(analysis.width, 320),