ML-95 Live histogram (#97)

* Added histogram and separated camera view builder

* Added histogram to `MeteringScreenLayoutConfig`

* `ResolutionPreset.medium` -> `ResolutionPreset.low`

* Adjusted histogram paddings
This commit is contained in:
Vadim 2023-08-06 16:28:20 +02:00 committed by GitHub
parent 1310b78a54
commit 886188bb9e
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
20 changed files with 280 additions and 54 deletions

View file

@ -76,7 +76,7 @@ jobs:
- name: Build Apk
env:
FLAVOR: ${{ github.event.inputs.flavor }}
run: flutter build apk --release --flavor $FLAVOR --dart-define cameraPreviewAspectRatio=2/3 -t lib/main_$FLAVOR.dart
run: flutter build apk --release --flavor $FLAVOR --dart-define c -t lib/main_$FLAVOR.dart
- name: Upload artifact
uses: actions/upload-artifact@v3

View file

@ -16,7 +16,7 @@ on:
type: string
env:
BUILD_ARGS: --release --flavor prod --dart-define cameraPreviewAspectRatio=2/3 -t lib/main_prod.dart
BUILD_ARGS: --release --flavor prod --dart-define cameraPreviewAspectRatio=240/320 -t lib/main_prod.dart
jobs:
build:

4
.vscode/launch.json vendored
View file

@ -12,7 +12,7 @@
"--flavor",
"dev",
"--dart-define",
"cameraPreviewAspectRatio=2/3",
"cameraPreviewAspectRatio=240/320",
],
"program": "${workspaceFolder}/lib/main_dev.dart",
},
@ -36,7 +36,7 @@
"--flavor",
"prod",
"--dart-define",
"cameraPreviewAspectRatio=2/3",
"cameraPreviewAspectRatio=240/320",
],
"program": "${workspaceFolder}/lib/main_prod.dart",
},

6
.vscode/tasks.json vendored
View file

@ -12,7 +12,7 @@
"dev",
"--release",
"--dart-define",
"cameraPreviewAspectRatio=2/3",
"cameraPreviewAspectRatio=240/320",
"-t",
"lib/main_dev.dart",
],
@ -28,7 +28,7 @@
"prod",
"--release",
"--dart-define",
"cameraPreviewAspectRatio=2/3",
"cameraPreviewAspectRatio=240/320",
"-t",
"lib/main_prod.dart",
],
@ -44,7 +44,7 @@
"prod",
"--release",
"--dart-define",
"cameraPreviewAspectRatio=2/3",
"cameraPreviewAspectRatio=240/320",
"-t",
"lib/main_prod.dart",
],

View file

@ -45,11 +45,11 @@ flutter pub get
flutter pub run intl_utils:generate
```
### 4. Build
### 4. Build (Android)
You can build an apk by running the following command from the root of the repository:
```console
flutter build apk --release --flavor $FLAVOR --dart-define cameraPreviewAspectRatio=2/3 -t lib/main_$FLAVOR.dart
flutter build apk --release --flavor $FLAVOR --dart-define cameraPreviewAspectRatio=240/320 -t lib/main_$FLAVOR.dart
```
Just replace `$FLAVOR` with `dev` or `prod`.

View file

@ -1,11 +1,13 @@
enum MeteringScreenLayoutFeature { extremeExposurePairs, filmPicker }
enum MeteringScreenLayoutFeature { extremeExposurePairs, filmPicker, histogram }
typedef MeteringScreenLayoutConfig = Map<MeteringScreenLayoutFeature, bool>;
extension MeteringScreenLayoutConfigJson on MeteringScreenLayoutConfig {
static MeteringScreenLayoutConfig fromJson(Map<String, dynamic> data) => data.map(
(key, value) => MapEntry(MeteringScreenLayoutFeature.values[int.parse(key)], value as bool),
);
static MeteringScreenLayoutConfig fromJson(Map<String, dynamic> data) =>
<MeteringScreenLayoutFeature, bool>{
for (final f in MeteringScreenLayoutFeature.values)
f: data[f.index.toString()] as bool? ?? true
};
Map<String, dynamic> toJson() => map((key, value) => MapEntry(key.index.toString(), value));
}

View file

@ -97,6 +97,7 @@ class UserPreferencesService {
return {
MeteringScreenLayoutFeature.extremeExposurePairs: true,
MeteringScreenLayoutFeature.filmPicker: true,
MeteringScreenLayoutFeature.histogram: true,
};
}
}

View file

@ -38,6 +38,7 @@
"meteringScreenLayoutHint": "Hide elements on the metering screen that you don't need so that they don't waste exposure pairs list space.",
"meteringScreenFeatureExtremeExposurePairs": "Fastest & shortest exposure pairs",
"meteringScreenFeatureFilmPicker": "Film picker",
"meteringScreenFeatureHistogram": "Histogram",
"film": "Film",
"equipment": "Equipment",
"equipmentProfileName": "Equipment profile name",

View file

@ -38,6 +38,7 @@
"meteringScreenLayoutHint": "Masquer les éléments sur l'écran de mesure dont vous n'avez pas besoin pour qu'ils ne gaspillent pas de l'espace dans les paires d'exposition.",
"meteringScreenFeatureExtremeExposurePairs": "Paires d'exposition les plus rapides et les plus courtes",
"meteringScreenFeatureFilmPicker": "Sélecteur de film",
"meteringScreenFeatureHistogram": "Histogramme",
"film": "Pellicule",
"equipment": "Équipement",
"equipmentProfileName": "Nom du profil de l'équipement",

View file

@ -38,6 +38,7 @@
"meteringScreenLayoutHint": "Здесь вы можете скрыть некоторые ненужные или неиспользуемые элементы с главного экрана.",
"meteringScreenFeatureExtremeExposurePairs": "Длинная и короткая выдержки",
"meteringScreenFeatureFilmPicker": "Выбор пленки",
"meteringScreenFeatureHistogram": "Гистограмма",
"film": "Пленка",
"equipment": "Оборудование",
"equipmentProfileName": "Название профиля",

View file

@ -38,6 +38,7 @@
"meteringScreenLayoutHint": "隐藏不需要的元素,以免浪费曝光列表空间",
"meteringScreenFeatureExtremeExposurePairs": "最快 & 最慢曝光组合",
"meteringScreenFeatureFilmPicker": "胶片选择",
"meteringScreenFeatureHistogram": "直方图",
"film": "胶片",
"equipment": "设备",
"equipmentProfileName": "设备配置名称",

View file

@ -123,7 +123,7 @@ class CameraContainerBloc extends EvSourceBlocBase<CameraContainerEvent, CameraC
(camera) => camera.lensDirection == CameraLensDirection.back,
orElse: () => cameras.last,
),
ResolutionPreset.medium,
ResolutionPreset.low,
enableAudio: false,
);

View file

@ -14,10 +14,12 @@ class CameraView extends StatelessWidget {
valueListenable: controller,
builder: (_, __, ___) => AspectRatio(
aspectRatio: _isLandscape(value) ? value.aspectRatio : (1 / value.aspectRatio),
child: RotatedBox(
quarterTurns: _getQuarterTurns(value),
child: controller.buildPreview(),
),
child: value.isInitialized
? RotatedBox(
quarterTurns: _getQuarterTurns(value),
child: controller.buildPreview(),
)
: const SizedBox.shrink(),
),
);
}

View file

@ -0,0 +1,132 @@
import 'dart:math';
import 'package:camera/camera.dart';
import 'package:flutter/material.dart';
import 'package:lightmeter/res/dimens.dart';
class CameraHistogram extends StatefulWidget {
final CameraController controller;
const CameraHistogram({required this.controller, super.key});
@override
_CameraHistogramState createState() => _CameraHistogramState();
}
class _CameraHistogramState extends State<CameraHistogram> {
List<int> histogramR = List.filled(256, 0);
List<int> histogramG = List.filled(256, 0);
List<int> histogramB = List.filled(256, 0);
@override
void initState() {
super.initState();
WidgetsBinding.instance.addPostFrameCallback((_) {
_startImageStream();
});
}
@override
void dispose() {
widget.controller.stopImageStream();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
mainAxisSize: MainAxisSize.min,
children: [
HistogramChannel(
color: Colors.red,
values: histogramR,
),
const SizedBox(height: Dimens.grid4),
HistogramChannel(
color: Colors.green,
values: histogramG,
),
const SizedBox(height: Dimens.grid4),
HistogramChannel(
color: Colors.blue,
values: histogramB,
),
],
);
}
void _startImageStream() {
widget.controller.startImageStream((CameraImage image) {
histogramR = List.filled(256, 0);
histogramG = List.filled(256, 0);
histogramB = List.filled(256, 0);
final int uvRowStride = image.planes[1].bytesPerRow;
final int uvPixelStride = image.planes[1].bytesPerPixel!;
for (int x = 0; x < image.width; x++) {
for (int y = 0; y < image.height; y++) {
final int uvIndex = uvPixelStride * (x / 2).floor() + uvRowStride * (y / 2).floor();
final int index = y * image.width + x;
final yp = image.planes[0].bytes[index];
final up = image.planes[1].bytes[uvIndex];
final vp = image.planes[2].bytes[uvIndex];
final r = yp + vp * 1436 / 1024 - 179;
final g = yp - up * 46549 / 131072 + 44 - vp * 93604 / 131072 + 91;
final b = yp + up * 1814 / 1024 - 227;
histogramR[r.round().clamp(0, 255)]++;
histogramG[g.round().clamp(0, 255)]++;
histogramB[b.round().clamp(0, 255)]++;
}
}
if (mounted) setState(() {});
});
}
}
class HistogramChannel extends StatelessWidget {
final List<int> values;
final Color color;
final int _maxOccurences;
HistogramChannel({
required this.values,
required this.color,
super.key,
}) : _maxOccurences = values.reduce((value, element) => max(value, element));
@override
Widget build(BuildContext context) {
return LayoutBuilder(
builder: (context, constraints) {
final pixelWidth = constraints.maxWidth / values.length;
return Column(
children: [
SizedBox(
height: Dimens.grid16,
child: Row(
crossAxisAlignment: CrossAxisAlignment.end,
children: values
.map(
(e) => SizedBox(
height: _maxOccurences == 0 ? 0 : Dimens.grid16 * (e / _maxOccurences),
width: pixelWidth,
child: ColoredBox(color: color),
),
)
.toList(),
),
),
const Divider(),
],
);
},
);
}
}

View file

@ -0,0 +1,62 @@
import 'package:camera/camera.dart';
import 'package:flutter/material.dart';
import 'package:lightmeter/data/models/metering_screen_layout_config.dart';
import 'package:lightmeter/platform_config.dart';
import 'package:lightmeter/providers/metering_screen_layout_provider.dart';
import 'package:lightmeter/res/dimens.dart';
import 'package:lightmeter/screens/metering/components/camera_container/components/camera_preview/components/camera_view/widget_camera_view.dart';
import 'package:lightmeter/screens/metering/components/camera_container/components/camera_preview/components/camera_view_placeholder/widget_placeholder_camera_view.dart';
import 'package:lightmeter/screens/metering/components/camera_container/components/camera_preview/components/histogram/widget_histogram.dart';
import 'package:lightmeter/screens/metering/components/camera_container/models/camera_error_type.dart';
class CameraPreview extends StatefulWidget {
final CameraController? controller;
final CameraErrorType? error;
const CameraPreview({this.controller, this.error, super.key});
@override
State<CameraPreview> createState() => _CameraPreviewState();
}
class _CameraPreviewState extends State<CameraPreview> {
@override
Widget build(BuildContext context) {
return AspectRatio(
aspectRatio: PlatformConfig.cameraPreviewAspectRatio,
child: Center(
child: Stack(
children: [
const CameraViewPlaceholder(error: null),
AnimatedSwitcher(
duration: Dimens.switchDuration,
child: widget.controller != null
? ValueListenableBuilder<CameraValue>(
valueListenable: widget.controller!,
builder: (_, __, ___) => widget.controller!.value.isInitialized
? Stack(
alignment: Alignment.bottomCenter,
children: [
CameraView(controller: widget.controller!),
if (MeteringScreenLayout.featureOf(
context,
MeteringScreenLayoutFeature.histogram,
))
Positioned(
left: Dimens.grid8,
right: Dimens.grid8,
bottom: Dimens.grid16,
child: CameraHistogram(controller: widget.controller!),
),
],
)
: const SizedBox.shrink(),
)
: CameraViewPlaceholder(error: widget.error),
),
],
),
),
);
}
}

View file

@ -10,8 +10,7 @@ import 'package:lightmeter/res/dimens.dart';
import 'package:lightmeter/screens/metering/components/camera_container/bloc_container_camera.dart';
import 'package:lightmeter/screens/metering/components/camera_container/components/camera_controls/widget_camera_controls.dart';
import 'package:lightmeter/screens/metering/components/camera_container/components/camera_controls_placeholder/widget_placeholder_camera_controls.dart';
import 'package:lightmeter/screens/metering/components/camera_container/components/camera_view/widget_camera_view.dart';
import 'package:lightmeter/screens/metering/components/camera_container/components/camera_view_placeholder/widget_placeholder_camera_view.dart';
import 'package:lightmeter/screens/metering/components/camera_container/components/camera_preview/widget_camera_preview.dart';
import 'package:lightmeter/screens/metering/components/camera_container/event_container_camera.dart';
import 'package:lightmeter/screens/metering/components/camera_container/models/camera_error_type.dart';
import 'package:lightmeter/screens/metering/components/camera_container/state_container_camera.dart';
@ -107,20 +106,11 @@ class _CameraViewBuilder extends StatelessWidget {
@override
Widget build(BuildContext context) {
return AspectRatio(
aspectRatio: PlatformConfig.cameraPreviewAspectRatio,
child: BlocBuilder<CameraContainerBloc, CameraContainerState>(
buildWhen: (previous, current) => current is! CameraActiveState,
builder: (context, state) => Center(
child: AnimatedSwitcher(
duration: Dimens.durationM,
child: switch (state) {
CameraInitializedState() => CameraView(controller: state.controller),
CameraErrorState() => CameraViewPlaceholder(error: state.error),
_ => const CameraViewPlaceholder(error: null),
},
),
),
return BlocBuilder<CameraContainerBloc, CameraContainerState>(
buildWhen: (previous, current) => current is! CameraActiveState,
builder: (context, state) => CameraPreview(
controller: state is CameraInitializedState ? state.controller : null,
error: state is CameraErrorState ? state.error : null,
),
);
}
@ -161,7 +151,9 @@ class _CameraControlsBuilder extends StatelessWidget {
},
);
} else {
child = const Column(children: [Expanded(child: SizedBox.shrink())],);
child = const Column(
children: [Expanded(child: SizedBox.shrink())],
);
}
return AnimatedSwitcher(

View file

@ -71,6 +71,8 @@ class _MeteringScreenLayoutFeaturesDialogState extends State<MeteringScreenLayou
return S.of(context).meteringScreenFeatureExtremeExposurePairs;
case MeteringScreenLayoutFeature.filmPicker:
return S.of(context).meteringScreenFeatureFilmPicker;
case MeteringScreenLayoutFeature.histogram:
return S.of(context).meteringScreenFeatureHistogram;
}
}
}

View file

@ -2,30 +2,56 @@ import 'package:lightmeter/data/models/metering_screen_layout_config.dart';
import 'package:test/test.dart';
void main() {
test('fromJson', () {
expect(
MeteringScreenLayoutConfigJson.fromJson({'0': true, '1': true}),
{
MeteringScreenLayoutFeature.extremeExposurePairs: true,
MeteringScreenLayoutFeature.filmPicker: true,
},
);
expect(
MeteringScreenLayoutConfigJson.fromJson({'0': false, '1': false}),
{
MeteringScreenLayoutFeature.extremeExposurePairs: false,
MeteringScreenLayoutFeature.filmPicker: false,
},
);
});
group(
'fromJson()',
() {
test('All keys', () {
expect(
MeteringScreenLayoutConfigJson.fromJson(
{
'0': true,
'1': true,
'2': true,
},
),
{
MeteringScreenLayoutFeature.extremeExposurePairs: true,
MeteringScreenLayoutFeature.filmPicker: true,
MeteringScreenLayoutFeature.histogram: true,
},
);
});
test('Legacy (no equipment profiles)', () {
expect(
MeteringScreenLayoutConfigJson.fromJson(
{
'0': true,
'1': true,
},
),
{
MeteringScreenLayoutFeature.extremeExposurePairs: true,
MeteringScreenLayoutFeature.filmPicker: true,
MeteringScreenLayoutFeature.histogram: true,
},
);
});
},
);
test('toJson', () {
expect(
{
MeteringScreenLayoutFeature.extremeExposurePairs: true,
MeteringScreenLayoutFeature.filmPicker: true,
MeteringScreenLayoutFeature.histogram: true,
}.toJson(),
{'0': true, '1': true},
{
'0': true,
'1': true,
'2': true,
},
);
});
}

View file

@ -193,6 +193,7 @@ void main() {
{
MeteringScreenLayoutFeature.extremeExposurePairs: true,
MeteringScreenLayoutFeature.filmPicker: true,
MeteringScreenLayoutFeature.histogram: true,
},
);
});
@ -206,6 +207,7 @@ void main() {
{
MeteringScreenLayoutFeature.extremeExposurePairs: false,
MeteringScreenLayoutFeature.filmPicker: true,
MeteringScreenLayoutFeature.histogram: true,
},
);
});
@ -214,17 +216,18 @@ void main() {
when(
() => sharedPreferences.setString(
UserPreferencesService.meteringScreenLayoutKey,
"""{"0":false,"1":true}""",
"""{"0":false,"1":true,"2":true}""",
),
).thenAnswer((_) => Future.value(true));
service.meteringScreenLayout = {
MeteringScreenLayoutFeature.extremeExposurePairs: false,
MeteringScreenLayoutFeature.filmPicker: true,
MeteringScreenLayoutFeature.histogram: true,
};
verify(
() => sharedPreferences.setString(
UserPreferencesService.meteringScreenLayoutKey,
"""{"0":false,"1":true}""",
"""{"0":false,"1":true,"2":true}""",
),
).called(1);
});