mirror of
https://github.com/immich-app/immich.git
synced 2026-05-18 03:10:24 +03:00
refactor: ocr_overlay widget
This commit is contained in:
@@ -94,33 +94,59 @@ class _OcrOverlayState extends ConsumerState<OcrOverlay> {
|
|||||||
if (data == null || data.isEmpty) {
|
if (data == null || data.isEmpty) {
|
||||||
return const SizedBox.shrink();
|
return const SizedBox.shrink();
|
||||||
}
|
}
|
||||||
return _buildOcrBoxes(data);
|
return _OcrBoxes(
|
||||||
|
ocrData: data,
|
||||||
|
controller: widget.controller,
|
||||||
|
imageSize: widget.imageSize,
|
||||||
|
viewportSize: widget.viewportSize,
|
||||||
|
controllerValue: _controllerValue,
|
||||||
|
selectedBoxIndex: _selectedBoxIndex,
|
||||||
|
onSelectionChanged: (index) => setState(() => _selectedBoxIndex = index),
|
||||||
|
);
|
||||||
},
|
},
|
||||||
loading: () => const SizedBox.shrink(),
|
loading: () => const SizedBox.shrink(),
|
||||||
error: (_, __) => const SizedBox.shrink(),
|
error: (_, __) => const SizedBox.shrink(),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Widget _buildOcrBoxes(List<Ocr> ocrData) {
|
class _OcrBoxes extends StatelessWidget {
|
||||||
|
final List<Ocr> ocrData;
|
||||||
|
final PhotoViewControllerBase? controller;
|
||||||
|
final Size imageSize;
|
||||||
|
final Size viewportSize;
|
||||||
|
final PhotoViewControllerValue? controllerValue;
|
||||||
|
final int? selectedBoxIndex;
|
||||||
|
final ValueChanged<int?> onSelectionChanged;
|
||||||
|
|
||||||
|
const _OcrBoxes({
|
||||||
|
required this.ocrData,
|
||||||
|
required this.controller,
|
||||||
|
required this.imageSize,
|
||||||
|
required this.viewportSize,
|
||||||
|
required this.controllerValue,
|
||||||
|
required this.selectedBoxIndex,
|
||||||
|
required this.onSelectionChanged,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
// Use the actual decoded image size from PhotoView's scaleBoundaries when
|
// Use the actual decoded image size from PhotoView's scaleBoundaries when
|
||||||
// available. The image provider may serve a downscaled preview (e.g. Immich
|
// available. The image provider may serve a downscaled preview (e.g. Immich
|
||||||
// serves a ~1440px preview for large originals), so the decoded dimensions
|
// serves a ~1440px preview for large originals), so the decoded dimensions
|
||||||
// can differ significantly from the stored asset dimensions. Using the wrong
|
// can differ significantly from the stored asset dimensions. Using the wrong
|
||||||
// size would scale every coordinate by the ratio between the two resolutions.
|
// size would scale every coordinate by the ratio between the two resolutions.
|
||||||
final imageSize = widget.controller?.scaleBoundaries?.childSize ?? widget.imageSize;
|
final resolvedImageSize = controller?.scaleBoundaries?.childSize ?? imageSize;
|
||||||
|
|
||||||
final scale =
|
final scale =
|
||||||
_controllerValue?.scale ??
|
controllerValue?.scale ??
|
||||||
math.min(widget.viewportSize.width / imageSize.width, widget.viewportSize.height / imageSize.height);
|
math.min(viewportSize.width / resolvedImageSize.width, viewportSize.height / resolvedImageSize.height);
|
||||||
final position = _controllerValue?.position ?? Offset.zero;
|
final position = controllerValue?.position ?? Offset.zero;
|
||||||
return _buildBoxStack(ocrData, imageSize, scale, position);
|
|
||||||
}
|
|
||||||
|
|
||||||
Widget _buildBoxStack(List<Ocr> ocrData, Size imageSize, double scale, Offset position) {
|
final imageWidth = resolvedImageSize.width;
|
||||||
final imageWidth = imageSize.width;
|
final imageHeight = resolvedImageSize.height;
|
||||||
final imageHeight = imageSize.height;
|
final viewportWidth = viewportSize.width;
|
||||||
final viewportWidth = widget.viewportSize.width;
|
final viewportHeight = viewportSize.height;
|
||||||
final viewportHeight = widget.viewportSize.height;
|
|
||||||
|
|
||||||
// Image center in viewport space, accounting for pan
|
// Image center in viewport space, accounting for pan
|
||||||
final cx = viewportWidth / 2 + position.dx;
|
final cx = viewportWidth / 2 + position.dx;
|
||||||
@@ -128,11 +154,7 @@ class _OcrOverlayState extends ConsumerState<OcrOverlay> {
|
|||||||
|
|
||||||
return GestureDetector(
|
return GestureDetector(
|
||||||
behavior: HitTestBehavior.translucent,
|
behavior: HitTestBehavior.translucent,
|
||||||
onTap: () {
|
onTap: () => onSelectionChanged(null),
|
||||||
setState(() {
|
|
||||||
_selectedBoxIndex = null;
|
|
||||||
});
|
|
||||||
},
|
|
||||||
child: ClipRect(
|
child: ClipRect(
|
||||||
child: Stack(
|
child: Stack(
|
||||||
children: [
|
children: [
|
||||||
@@ -141,7 +163,6 @@ class _OcrOverlayState extends ConsumerState<OcrOverlay> {
|
|||||||
...ocrData.asMap().entries.map((entry) {
|
...ocrData.asMap().entries.map((entry) {
|
||||||
final index = entry.key;
|
final index = entry.key;
|
||||||
final ocr = entry.value;
|
final ocr = entry.value;
|
||||||
final isSelected = _selectedBoxIndex == index;
|
|
||||||
|
|
||||||
// Map normalized image coords (0–1) to viewport space
|
// Map normalized image coords (0–1) to viewport space
|
||||||
final x1 = cx + (ocr.x1 - 0.5) * imageWidth * scale;
|
final x1 = cx + (ocr.x1 - 0.5) * imageWidth * scale;
|
||||||
@@ -159,80 +180,25 @@ class _OcrOverlayState extends ConsumerState<OcrOverlay> {
|
|||||||
final minY = [y1, y2, y3, y4].reduce((a, b) => a < b ? a : b);
|
final minY = [y1, y2, y3, y4].reduce((a, b) => a < b ? a : b);
|
||||||
final maxY = [y1, y2, y3, y4].reduce((a, b) => a > b ? a : b);
|
final maxY = [y1, y2, y3, y4].reduce((a, b) => a > b ? a : b);
|
||||||
|
|
||||||
final angle = math.atan2(y2 - y1, x2 - x1);
|
return _OcrBoxItem(
|
||||||
final centerX = (minX + maxX) / 2;
|
key: ValueKey(index),
|
||||||
final centerY = (minY + maxY) / 2;
|
ocr: ocr,
|
||||||
|
index: index,
|
||||||
return Positioned(
|
isSelected: selectedBoxIndex == index,
|
||||||
|
points: [
|
||||||
|
Offset(x1 - minX, y1 - minY),
|
||||||
|
Offset(x2 - minX, y2 - minY),
|
||||||
|
Offset(x3 - minX, y3 - minY),
|
||||||
|
Offset(x4 - minX, y4 - minY),
|
||||||
|
],
|
||||||
left: minX,
|
left: minX,
|
||||||
top: minY,
|
top: minY,
|
||||||
child: GestureDetector(
|
width: maxX - minX,
|
||||||
onTap: () {
|
height: maxY - minY,
|
||||||
setState(() {
|
angle: math.atan2(y2 - y1, x2 - x1),
|
||||||
_selectedBoxIndex = isSelected ? null : index;
|
labelDx: (minX + maxX) / 2 - minX,
|
||||||
});
|
labelDy: (minY + maxY) / 2 - minY,
|
||||||
},
|
onSelectionChanged: onSelectionChanged,
|
||||||
behavior: HitTestBehavior.translucent,
|
|
||||||
child: SizedBox(
|
|
||||||
width: maxX - minX,
|
|
||||||
height: maxY - minY,
|
|
||||||
child: Stack(
|
|
||||||
children: [
|
|
||||||
CustomPaint(
|
|
||||||
painter: _OcrBoxPainter(
|
|
||||||
points: [
|
|
||||||
Offset(x1 - minX, y1 - minY),
|
|
||||||
Offset(x2 - minX, y2 - minY),
|
|
||||||
Offset(x3 - minX, y3 - minY),
|
|
||||||
Offset(x4 - minX, y4 - minY),
|
|
||||||
],
|
|
||||||
isSelected: isSelected,
|
|
||||||
colorScheme: context.themeData.colorScheme,
|
|
||||||
),
|
|
||||||
size: Size(maxX - minX, maxY - minY),
|
|
||||||
),
|
|
||||||
if (isSelected)
|
|
||||||
Positioned(
|
|
||||||
left: centerX - minX,
|
|
||||||
top: centerY - minY,
|
|
||||||
child: FractionalTranslation(
|
|
||||||
translation: const Offset(-0.5, -0.5),
|
|
||||||
child: Transform.rotate(
|
|
||||||
angle: angle,
|
|
||||||
alignment: Alignment.center,
|
|
||||||
child: Container(
|
|
||||||
margin: const EdgeInsets.all(2),
|
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 2),
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: Colors.grey[800]?.withValues(alpha: 0.4),
|
|
||||||
borderRadius: const BorderRadius.all(Radius.circular(4)),
|
|
||||||
),
|
|
||||||
child: ConstrainedBox(
|
|
||||||
constraints: BoxConstraints(
|
|
||||||
maxWidth: math.max(50, maxX - minX),
|
|
||||||
maxHeight: math.max(20, maxY - minY),
|
|
||||||
),
|
|
||||||
child: FittedBox(
|
|
||||||
fit: BoxFit.scaleDown,
|
|
||||||
child: SelectableText(
|
|
||||||
ocr.text,
|
|
||||||
style: TextStyle(
|
|
||||||
color: Colors.white,
|
|
||||||
fontSize: math.max(12, (maxY - minY) * 0.6),
|
|
||||||
fontWeight: FontWeight.bold,
|
|
||||||
),
|
|
||||||
textAlign: TextAlign.center,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
}),
|
}),
|
||||||
],
|
],
|
||||||
@@ -242,6 +208,103 @@ class _OcrOverlayState extends ConsumerState<OcrOverlay> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
class _OcrBoxItem extends StatelessWidget {
|
||||||
|
final Ocr ocr;
|
||||||
|
final int index;
|
||||||
|
final bool isSelected;
|
||||||
|
final List<Offset> points;
|
||||||
|
final double left;
|
||||||
|
final double top;
|
||||||
|
final double width;
|
||||||
|
final double height;
|
||||||
|
final double angle;
|
||||||
|
final double labelDx;
|
||||||
|
final double labelDy;
|
||||||
|
final ValueChanged<int?> onSelectionChanged;
|
||||||
|
|
||||||
|
const _OcrBoxItem({
|
||||||
|
super.key,
|
||||||
|
required this.ocr,
|
||||||
|
required this.index,
|
||||||
|
required this.isSelected,
|
||||||
|
required this.points,
|
||||||
|
required this.left,
|
||||||
|
required this.top,
|
||||||
|
required this.width,
|
||||||
|
required this.height,
|
||||||
|
required this.angle,
|
||||||
|
required this.labelDx,
|
||||||
|
required this.labelDy,
|
||||||
|
required this.onSelectionChanged,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Positioned(
|
||||||
|
left: left,
|
||||||
|
top: top,
|
||||||
|
child: GestureDetector(
|
||||||
|
onTap: () => onSelectionChanged(isSelected ? null : index),
|
||||||
|
behavior: HitTestBehavior.translucent,
|
||||||
|
child: SizedBox(
|
||||||
|
width: width,
|
||||||
|
height: height,
|
||||||
|
child: Stack(
|
||||||
|
children: [
|
||||||
|
CustomPaint(
|
||||||
|
painter: _OcrBoxPainter(
|
||||||
|
points: points,
|
||||||
|
isSelected: isSelected,
|
||||||
|
colorScheme: context.themeData.colorScheme,
|
||||||
|
),
|
||||||
|
size: Size(width, height),
|
||||||
|
),
|
||||||
|
if (isSelected)
|
||||||
|
Positioned(
|
||||||
|
left: labelDx,
|
||||||
|
top: labelDy,
|
||||||
|
child: FractionalTranslation(
|
||||||
|
translation: const Offset(-0.5, -0.5),
|
||||||
|
child: Transform.rotate(
|
||||||
|
angle: angle,
|
||||||
|
alignment: Alignment.center,
|
||||||
|
child: Container(
|
||||||
|
margin: const EdgeInsets.all(2),
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 2),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.grey[800]?.withValues(alpha: 0.4),
|
||||||
|
borderRadius: const BorderRadius.all(Radius.circular(4)),
|
||||||
|
),
|
||||||
|
child: ConstrainedBox(
|
||||||
|
constraints: BoxConstraints(
|
||||||
|
maxWidth: math.max(50, width),
|
||||||
|
maxHeight: math.max(20, height),
|
||||||
|
),
|
||||||
|
child: FittedBox(
|
||||||
|
fit: BoxFit.scaleDown,
|
||||||
|
child: SelectableText(
|
||||||
|
ocr.text,
|
||||||
|
style: TextStyle(
|
||||||
|
color: Colors.white,
|
||||||
|
fontSize: math.max(12, height * 0.6),
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
),
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
class _OcrBoxPainter extends CustomPainter {
|
class _OcrBoxPainter extends CustomPainter {
|
||||||
final List<Offset> points;
|
final List<Offset> points;
|
||||||
final bool isSelected;
|
final bool isSelected;
|
||||||
@@ -273,6 +336,6 @@ class _OcrBoxPainter extends CustomPainter {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
bool shouldRepaint(_OcrBoxPainter oldDelegate) {
|
bool shouldRepaint(_OcrBoxPainter oldDelegate) {
|
||||||
return oldDelegate.isSelected != isSelected || listEquals(oldDelegate.points, points);
|
return oldDelegate.isSelected != isSelected || !listEquals(oldDelegate.points, points);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user