card-recog-demo/lib/screens/preview_screen.dart

377 lines
11 KiB
Dart
Raw Normal View History

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/card_cropper.dart';
import '../services/card_detector.dart';
import '../widgets/guide_frame.dart';
/// Post-capture: detect / lock border on still image, then crop for preview.
class PreviewScreen extends StatefulWidget {
const PreviewScreen({
super.key,
required this.imagePath,
this.captureViewport,
});
final String imagePath;
/// Camera preview viewport used to map the shooting guide into image pixels.
/// Null for gallery picks — guide is applied in image space directly.
final Size? captureViewport;
@override
State<PreviewScreen> createState() => _PreviewScreenState();
}
class _PreviewScreenState extends State<PreviewScreen> {
final _detector = CardDetector();
bool _loading = true;
String? _error;
ui.Image? _sourceImage;
CardBox? _locked;
CropResult? _crop;
String? _label;
double? _confidence;
@override
void initState() {
super.initState();
_run();
}
@override
void dispose() {
_sourceImage?.dispose();
_detector.dispose();
super.dispose();
}
Future<void> _run() async {
try {
// Decode with Flutter (handles gallery HEIC/EXIF well, keeps sharpness).
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();
// Initial crop to the shooting guide — exclude everything outside the frame.
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 dir = await getTemporaryDirectory();
final guidePath =
'${dir.path}/card_guide_${DateTime.now().millisecondsSinceEpoch}.jpg';
await File(guidePath).writeAsBytes(
img.encodeJpg(raster, quality: 100),
flush: true,
);
// Build a ui.Image for the guide-cropped preview.
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.detectFile(guidePath);
final guideSize = Size(
displayImage.width.toDouble(),
displayImage.height.toDouble(),
);
var best = _detector.pickBest(boxes, imageSize: guideSize);
if (best != null) {
best = _detector.refineCorners(raster, best);
}
final crop = await CardCropper.cropAndCorrect(
imagePath: guidePath,
box: best,
);
if (!mounted) {
displayImage.dispose();
return;
}
setState(() {
_sourceImage = displayImage;
_locked = best;
_crop = crop;
_label = best?.label;
_confidence = best?.confidence;
_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('正在锁定卡牌边框…'),
],
),
)
: _error != null
? Center(child: Text(_error!, textAlign: TextAlign.center))
: _buildContent(context),
),
),
);
}
Widget _buildContent(BuildContext context) {
final meta = _locked != null
? (_locked!.corners != null
? '已锁定倾斜边框${_label != null ? ' · $_label' : ''}'
'${_confidence != null ? ' · ${(_confidence! * 100).toStringAsFixed(0)}%' : ''}'
: '已锁定边框${_label != null ? ' · $_label' : ''}'
'${_confidence != null ? ' · ${(_confidence! * 100).toStringAsFixed(0)}%' : ''}')
: '未检出边框 · 已用引导框裁剪';
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text(
meta,
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: Colors.white70,
),
),
const SizedBox(height: 12),
Expanded(
flex: 5,
child: _LockedPhotoView(
image: _sourceImage!,
locked: _locked,
),
),
const SizedBox(height: 12),
Text(
'裁剪结果',
style: Theme.of(context).textTheme.titleSmall,
),
const SizedBox(height: 8),
Expanded(
flex: 4,
child: Center(
child: DecoratedBox(
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(12),
boxShadow: const [
BoxShadow(
color: Color(0x66000000),
blurRadius: 24,
offset: Offset(0, 12),
),
],
),
child: ClipRRect(
borderRadius: BorderRadius.circular(12),
child: Image.memory(
_crop!.bytes,
fit: BoxFit.contain,
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(52),
),
),
],
);
}
}
class _LockedPhotoView extends StatelessWidget {
const _LockedPhotoView({
required this.image,
required this.locked,
});
final ui.Image image;
final CardBox? locked;
@override
Widget build(BuildContext context) {
return LayoutBuilder(
builder: (context, constraints) {
final view = Size(constraints.maxWidth, constraints.maxHeight);
final img = Size(image.width.toDouble(), image.height.toDouble());
final fitted = _contain(img, 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: img,
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 CardBox 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 q = box.quad.map(_map).toList(growable: false);
final path = Path()
..moveTo(q[0].dx, q[0].dy)
..lineTo(q[1].dx, q[1].dy)
..lineTo(q[2].dx, q[2].dy)
..lineTo(q[3].dx, q[3].dy)
..close();
final paint = Paint()
..style = PaintingStyle.stroke
..strokeWidth = 3
..color = const Color(0xFF22C55E);
canvas.drawPath(path, paint);
final corner = Paint()
..color = const Color(0xFF22C55E)
..strokeWidth = 4
..style = PaintingStyle.stroke
..strokeCap = StrokeCap.round;
const len = 22.0;
for (var i = 0; i < 4; i++) {
final p = q[i];
final prev = q[(i + 3) % 4];
final next = q[(i + 1) % 4];
final toPrev = prev - p;
final toNext = next - p;
final dPrev = toPrev.distance;
final dNext = toNext.distance;
if (dPrev < 1 || dNext < 1) continue;
final a = p + toPrev * (len / dPrev);
final b = p + toNext * (len / dNext);
canvas.drawLine(p, a, corner);
canvas.drawLine(p, b, corner);
}
}
@override
bool shouldRepaint(covariant _LockPainter old) {
return old.box != box || old.fitted != fitted || old.imageSize != imageSize;
}
}