import 'dart:io'; import 'dart:ui' as ui; import 'package:flutter/material.dart'; import 'package:image/image.dart' as img; import 'package:path_provider/path_provider.dart'; import '../services/blur_detector.dart'; import '../services/coin_cropper.dart'; import '../services/coin_detector.dart'; import '../widgets/guide_frame.dart'; /// Post-capture: blur check → detect → contour refine → circular crop. class PreviewScreen extends StatefulWidget { const PreviewScreen({ super.key, required this.imagePath, this.captureViewport, }); final String imagePath; final Size? captureViewport; @override State createState() => _PreviewScreenState(); } class _PreviewScreenState extends State { final _detector = CoinDetector(); final _blur = BlurDetector(); bool _loading = true; String? _error; ui.Image? _sourceImage; CoinBox? _locked; CropResult? _crop; BlurResult? _blurResult; @override void initState() { super.initState(); _run(); } @override void dispose() { _sourceImage?.dispose(); _detector.dispose(); _blur.dispose(); super.dispose(); } Future _run() async { try { final raw = await File(widget.imagePath).readAsBytes(); final displayCodec = await ui.instantiateImageCodec(raw); final displayFrame = await displayCodec.getNextFrame(); final fullImage = displayFrame.image; final rgba = await fullImage.toByteData(format: ui.ImageByteFormat.rawRgba); if (rgba == null) { fullImage.dispose(); throw StateError('无法读取像素'); } final fullRaster = img.Image.fromBytes( width: fullImage.width, height: fullImage.height, bytes: rgba.buffer, bytesOffset: rgba.offsetInBytes, numChannels: 4, order: img.ChannelOrder.rgba, ); fullImage.dispose(); final imageSize = Size( fullRaster.width.toDouble(), fullRaster.height.toDouble(), ); final guide = widget.captureViewport != null ? GuideFrame.inCoverImage( viewport: widget.captureViewport!, imageSize: imageSize, ) : GuideFrame.inImage(imageSize); final gx = guide.left.round().clamp(0, fullRaster.width - 1); final gy = guide.top.round().clamp(0, fullRaster.height - 1); final gw = guide.width.round().clamp(1, fullRaster.width - gx); final gh = guide.height.round().clamp(1, fullRaster.height - gy); final raster = img.copyCrop( fullRaster, x: gx, y: gy, width: gw, height: gh, ); final blurResult = await _blur.evaluate(raster); if (blurResult.isBlurry) { if (!mounted) return; ScaffoldMessenger.of(context).showSnackBar( SnackBar( content: Text( '图像过模糊(清晰 ${(blurResult.clearScore * 100).toStringAsFixed(0)}%),请重拍', ), ), ); Navigator.of(context).pop(); return; } final dir = await getTemporaryDirectory(); final guidePath = '${dir.path}/coin_guide_${DateTime.now().millisecondsSinceEpoch}.jpg'; await File(guidePath).writeAsBytes( img.encodeJpg(raster, quality: 100), flush: true, ); final guideBytes = img.encodeJpg(raster, quality: 100); final guideCodec = await ui.instantiateImageCodec(guideBytes); final guideFrame = await guideCodec.getNextFrame(); final displayImage = guideFrame.image; final boxes = await _detector.detectImage(raster); final guideSize = Size( displayImage.width.toDouble(), displayImage.height.toDouble(), ); var best = _detector.pickBest(boxes, imageSize: guideSize); if (best == null) { displayImage.dispose(); if (!mounted) return; ScaffoldMessenger.of(context).showSnackBar( const SnackBar(content: Text('未检测到硬币,请重拍')), ); Navigator.of(context).pop(); return; } best = _detector.refineContour(raster, best); final crop = await CoinCropper.cropAndCorrect( imagePath: guidePath, box: best, ); if (!mounted) { displayImage.dispose(); return; } setState(() { _sourceImage = displayImage; _locked = best; _crop = crop; _blurResult = blurResult; _loading = false; }); } catch (e) { if (!mounted) return; setState(() { _error = '处理失败: $e'; _loading = false; }); } } @override Widget build(BuildContext context) { return Scaffold( appBar: AppBar( title: const Text('硬币轮廓锁定'), backgroundColor: Colors.transparent, ), body: SafeArea( child: Padding( padding: const EdgeInsets.fromLTRB(20, 8, 20, 24), child: _loading ? const Center( child: Column( mainAxisSize: MainAxisSize.min, children: [ CircularProgressIndicator(), SizedBox(height: 16), Text('blur → detect → 轮廓精修…'), ], ), ) : _error != null ? Center(child: Text(_error!, textAlign: TextAlign.center)) : _buildContent(context), ), ), ); } Widget _buildContent(BuildContext context) { final blur = _blurResult; final locked = _locked; final meta = [ if (blur != null) '清晰度 ${(blur.clearScore * 100).toStringAsFixed(0)}%', if (locked != null) '检测 ${(locked.confidence * 100).toStringAsFixed(0)}%', if (locked?.radius != null) '半径 ${locked!.radius!.round()}px', ].join(' · '); return Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ Text( meta, style: Theme.of(context).textTheme.bodyMedium?.copyWith( color: Colors.white70, ), ), const SizedBox(height: 8), Expanded( child: SingleChildScrollView( child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ SizedBox( height: 260, child: _LockedPhotoView( image: _sourceImage!, locked: _locked, ), ), const SizedBox(height: 12), Text('圆形裁剪', style: Theme.of(context).textTheme.titleSmall), const SizedBox(height: 6), SizedBox( height: 200, child: Center( child: ClipOval( child: ColoredBox( color: Colors.black26, child: Image.memory( _crop!.bytes, width: 180, height: 180, fit: BoxFit.cover, filterQuality: FilterQuality.high, ), ), ), ), ), const SizedBox(height: 16), FilledButton.icon( onPressed: () => Navigator.of(context).pop(), icon: const Icon(Icons.camera_alt_outlined), label: const Text('重新拍摄'), style: FilledButton.styleFrom( minimumSize: const Size.fromHeight(48), ), ), ], ), ), ), ], ); } } class _LockedPhotoView extends StatelessWidget { const _LockedPhotoView({required this.image, required this.locked}); final ui.Image image; final CoinBox? locked; @override Widget build(BuildContext context) { return LayoutBuilder( builder: (context, constraints) { final view = Size(constraints.maxWidth, constraints.maxHeight); final imgSize = Size(image.width.toDouble(), image.height.toDouble()); final fitted = _contain(imgSize, view); return ClipRRect( borderRadius: BorderRadius.circular(12), child: ColoredBox( color: Colors.black26, child: Stack( children: [ Positioned.fromRect( rect: fitted, child: RawImage( image: image, fit: BoxFit.fill, filterQuality: FilterQuality.high, ), ), if (locked != null) Positioned.fill( child: CustomPaint( painter: _LockPainter( box: locked!, imageSize: imgSize, fitted: fitted, ), ), ), ], ), ), ); }, ); } static Rect _contain(Size image, Size view) { final scale = (view.width / image.width < view.height / image.height) ? view.width / image.width : view.height / image.height; final w = image.width * scale; final h = image.height * scale; return Rect.fromLTWH( (view.width - w) / 2, (view.height - h) / 2, w, h, ); } } class _LockPainter extends CustomPainter { _LockPainter({ required this.box, required this.imageSize, required this.fitted, }); final CoinBox box; final Size imageSize; final Rect fitted; Offset _map(Offset p) => Offset( fitted.left + p.dx / imageSize.width * fitted.width, fitted.top + p.dy / imageSize.height * fitted.height, ); @override void paint(Canvas canvas, Size size) { final c = _map(box.effectiveCenter); final r = box.effectiveRadius / imageSize.width * fitted.width; final ring = Paint() ..style = PaintingStyle.stroke ..strokeWidth = 3 ..color = const Color(0xFFEAB308); canvas.drawCircle(c, r, ring); final aabb = Paint() ..style = PaintingStyle.stroke ..strokeWidth = 1.2 ..color = const Color(0x66EAB308); final tl = _map(box.rect.topLeft); final br = _map(box.rect.bottomRight); canvas.drawRect(Rect.fromPoints(tl, br), aabb); final cross = Paint() ..color = const Color(0xFFEAB308) ..strokeWidth = 2; const len = 10.0; canvas.drawLine(Offset(c.dx - len, c.dy), Offset(c.dx + len, c.dy), cross); canvas.drawLine(Offset(c.dx, c.dy - len), Offset(c.dx, c.dy + len), cross); } @override bool shouldRepaint(covariant _LockPainter old) { return old.box != box || old.fitted != fitted || old.imageSize != imageSize; } }