Add interactive manual circle drag/pinch with live color preview
This commit is contained in:
293
lib/main.dart
293
lib/main.dart
@@ -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,7 +218,32 @@ class _MosaicHomePageState extends State<MosaicHomePage> {
|
||||
? dominantColor
|
||||
: averageColor;
|
||||
_photoCapHexCtrl.text = _colorToHex(selected);
|
||||
setDialogState(() {});
|
||||
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(
|
||||
@@ -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),
|
||||
|
||||
Reference in New Issue
Block a user