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';
|
_photoCapNameCtrl.text = 'Fotografierter Deckel';
|
||||||
_photoCapHexCtrl.text = _colorToHex(selected);
|
_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,
|
context: context,
|
||||||
builder: (ctx) {
|
builder: (ctx) {
|
||||||
return StatefulBuilder(
|
return StatefulBuilder(
|
||||||
builder: (ctx, setDialogState) {
|
builder: (ctx, setDialogState) {
|
||||||
Future<void> recalculate() async {
|
Future<void> recalculate({bool updateDialog = true}) async {
|
||||||
|
final localToken = ++recalcToken;
|
||||||
|
if (updateDialog) {
|
||||||
|
setDialogState(() {});
|
||||||
|
}
|
||||||
final adjusted = await compute(
|
final adjusted = await compute(
|
||||||
_extractCapFromAdjustedCircleIsolate,
|
_extractCapFromAdjustedCircleIsolate,
|
||||||
<String, dynamic>{
|
<String, dynamic>{
|
||||||
@@ -192,6 +210,7 @@ class _MosaicHomePageState extends State<MosaicHomePage> {
|
|||||||
'circleR': circleR,
|
'circleR': circleR,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
if (localToken != recalcToken) return;
|
||||||
dominantColor = Color(adjusted['dominantColor'] as int);
|
dominantColor = Color(adjusted['dominantColor'] as int);
|
||||||
averageColor = Color(adjusted['averageColor'] as int);
|
averageColor = Color(adjusted['averageColor'] as int);
|
||||||
previewBytes = adjusted['previewPng'] as Uint8List;
|
previewBytes = adjusted['previewPng'] as Uint8List;
|
||||||
@@ -199,7 +218,32 @@ class _MosaicHomePageState extends State<MosaicHomePage> {
|
|||||||
? dominantColor
|
? dominantColor
|
||||||
: averageColor;
|
: averageColor;
|
||||||
_photoCapHexCtrl.text = _colorToHex(selected);
|
_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(
|
return AlertDialog(
|
||||||
@@ -209,16 +253,25 @@ class _MosaicHomePageState extends State<MosaicHomePage> {
|
|||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
ClipRRect(
|
_CircleAdjustOverlay(
|
||||||
borderRadius: BorderRadius.circular(12),
|
imageBytes: bytes,
|
||||||
child: Image.memory(previewBytes,
|
imageWidth: imageW,
|
||||||
width: 220, height: 220, fit: BoxFit.cover),
|
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),
|
const SizedBox(height: 8),
|
||||||
Text(
|
Text(
|
||||||
usedFallback
|
usedFallback
|
||||||
? 'Kreiserkennung per Fallback. Bei Bedarf Kreis manuell korrigieren.'
|
? 'Kreiserkennung per Fallback. Kreis direkt im Foto verschieben/zoomen.'
|
||||||
: 'Kreis automatisch erkannt. Optional unten feinjustieren.',
|
: 'Kreis erkannt. Direkt im Foto ziehen oder mit Pinch/Slider anpassen.',
|
||||||
style: Theme.of(context).textTheme.bodySmall,
|
style: Theme.of(context).textTheme.bodySmall,
|
||||||
),
|
),
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
@@ -243,42 +296,15 @@ class _MosaicHomePageState extends State<MosaicHomePage> {
|
|||||||
setDialogState(() {});
|
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(
|
Slider(
|
||||||
value: circleX.clamp(0.15, 0.85),
|
value: circleR,
|
||||||
min: 0.15,
|
min: 0.08,
|
||||||
max: 0.85,
|
max: 0.49,
|
||||||
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',
|
label: 'Radius',
|
||||||
onChanged: (v) => setDialogState(() => circleR = v),
|
onChanged: (v) {
|
||||||
),
|
setDialogState(() => setAndClampCircle(r: v));
|
||||||
Align(
|
scheduleLiveRecalculate();
|
||||||
alignment: Alignment.centerRight,
|
},
|
||||||
child: OutlinedButton.icon(
|
|
||||||
onPressed: recalculate,
|
|
||||||
icon: const Icon(Icons.tune),
|
|
||||||
label: const Text('Kreis anwenden & neu berechnen'),
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
Row(
|
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() {
|
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 {
|
class CapCatalogEntry {
|
||||||
final String id;
|
final String id;
|
||||||
String name;
|
String name;
|
||||||
@@ -1369,15 +1537,20 @@ Map<String, dynamic> _extractCapFromAdjustedCircleIsolate(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
final cx = ((request['circleX'] as num).toDouble() * decoded.width)
|
final minSide = math.min(decoded.width, decoded.height).toDouble();
|
||||||
.clamp(0.0, decoded.width.toDouble());
|
final normalized = _clampCircleNormalized(
|
||||||
final cy = ((request['circleY'] as num).toDouble() * decoded.height)
|
imageW: decoded.width.toDouble(),
|
||||||
.clamp(0.0, decoded.height.toDouble());
|
imageH: decoded.height.toDouble(),
|
||||||
final r = ((request['circleR'] as num).toDouble() *
|
circleX: (request['circleX'] as num).toDouble(),
|
||||||
math.min(decoded.width, decoded.height))
|
circleY: (request['circleY'] as num).toDouble(),
|
||||||
.clamp(8.0, math.min(decoded.width, decoded.height) * 0.49);
|
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);
|
final stats = _sampleCapColors(decoded, circle);
|
||||||
|
|
||||||
const int analysisMaxSize = 480;
|
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) {
|
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),
|
||||||
|
|||||||
Reference in New Issue
Block a user