diff --git a/app/lib/iterable_extension.dart b/app/lib/iterable_extension.dart index 3be61377..f253e157 100644 --- a/app/lib/iterable_extension.dart +++ b/app/lib/iterable_extension.dart @@ -82,6 +82,22 @@ extension IterableExtension on Iterable { return toList(); } } + + /// The first index of [element] in this iterable + /// + /// Searches the list from index start to the end of the list. The first time + /// an object o is encountered so that o == element, the index of o is + /// returned. Returns -1 if element is not found. + int indexOf(T element, [int start = 0]) { + var i = 0; + for (final e in this) { + if (e == element) { + return i; + } + ++i; + } + return -1; + } } extension IterableFlattenExtension on Iterable> { diff --git a/app/lib/l10n/app_en.arb b/app/lib/l10n/app_en.arb index 561869ec..d51c6b72 100644 --- a/app/lib/l10n/app_en.arb +++ b/app/lib/l10n/app_en.arb @@ -1256,6 +1256,51 @@ "@doubleTapExitNotification": { "description": "If double tap to exit is enabled in settings, shown when users tap the back button" }, + "imageEditDiscardDialogTitle": "Discard changes?", + "@imageEditDiscardDialogTitle": { + "description": "Warn before dismissing image editor (e.g., user pressing back button)" + }, + "imageEditDiscardDialogContent": "Your changes are not saved", + "discardButtonLabel": "DISCARD", + "@discardButtonLabel": { + "description": "Discard the current unsaved content" + }, + "saveTooltip": "Save", + "@saveTooltip": { + "description": "Save the current content" + }, + "imageEditColorBrightness": "Brightness", + "@imageEditColorBrightness": { + "description": "Adjust the brightness of an image" + }, + "imageEditColorContrast": "Contrast", + "@imageEditColorContrast": { + "description": "Adjust the contrast of an image" + }, + "imageEditColorWhitePoint": "White point", + "@imageEditColorWhitePoint": { + "description": "Adjust the white point of an image. Learn more about this adjustment: https://www.photoreview.com.au/tips/editing/advanced-levels-adjustments" + }, + "imageEditColorBlackPoint": "Black point", + "@imageEditColorBlackPoint": { + "description": "Adjust the black point of an image" + }, + "imageEditColorSaturation": "Saturation", + "@imageEditColorSaturation": { + "description": "Adjust the color saturation of an image" + }, + "imageEditColorWarmth": "Warmth", + "@imageEditColorWarmth": { + "description": "This roughly equals to adjusting the color temperature of an image. The end result is to shift the image colors such that it looks 'warmer' or 'cooler'" + }, + "imageEditColorTint": "Tint", + "@imageEditColorTint": { + "description": "Shift colors from a green to a magenta tint" + }, + "imageEditTitle": "Preview edits", + "@imageEditTitle": { + "description": "Title of the image editor" + }, "errorUnauthenticated": "Unauthenticated access. Please sign-in again if the problem continues", "@errorUnauthenticated": { diff --git a/app/lib/l10n/untranslated-messages.txt b/app/lib/l10n/untranslated-messages.txt index 6280218d..3fce1549 100644 --- a/app/lib/l10n/untranslated-messages.txt +++ b/app/lib/l10n/untranslated-messages.txt @@ -112,6 +112,18 @@ "enhanceStyleTransferTitle", "enhanceStyleTransferStyleDialogTitle", "doubleTapExitNotification", + "imageEditDiscardDialogTitle", + "imageEditDiscardDialogContent", + "discardButtonLabel", + "saveTooltip", + "imageEditColorBrightness", + "imageEditColorContrast", + "imageEditColorWhitePoint", + "imageEditColorBlackPoint", + "imageEditColorSaturation", + "imageEditColorWarmth", + "imageEditColorTint", + "imageEditTitle", "errorAlbumDowngrade" ], @@ -242,6 +254,18 @@ "enhanceStyleTransferTitle", "enhanceStyleTransferStyleDialogTitle", "doubleTapExitNotification", + "imageEditDiscardDialogTitle", + "imageEditDiscardDialogContent", + "discardButtonLabel", + "saveTooltip", + "imageEditColorBrightness", + "imageEditColorContrast", + "imageEditColorWhitePoint", + "imageEditColorBlackPoint", + "imageEditColorSaturation", + "imageEditColorWarmth", + "imageEditColorTint", + "imageEditTitle", "errorAlbumDowngrade" ], @@ -252,11 +276,50 @@ "settingsAccountLabelDescription", "settingsDoubleTapExitTitle", "enhanceStyleTransferStyleDialogTitle", - "doubleTapExitNotification" + "doubleTapExitNotification", + "imageEditDiscardDialogTitle", + "imageEditDiscardDialogContent", + "discardButtonLabel", + "saveTooltip", + "imageEditColorBrightness", + "imageEditColorContrast", + "imageEditColorWhitePoint", + "imageEditColorBlackPoint", + "imageEditColorSaturation", + "imageEditColorWarmth", + "imageEditColorTint", + "imageEditTitle" ], "es": [ - "rootPickerSkipConfirmationDialogContent2" + "rootPickerSkipConfirmationDialogContent2", + "imageEditDiscardDialogTitle", + "imageEditDiscardDialogContent", + "discardButtonLabel", + "saveTooltip", + "imageEditColorBrightness", + "imageEditColorContrast", + "imageEditColorWhitePoint", + "imageEditColorBlackPoint", + "imageEditColorSaturation", + "imageEditColorWarmth", + "imageEditColorTint", + "imageEditTitle" + ], + + "fi": [ + "imageEditDiscardDialogTitle", + "imageEditDiscardDialogContent", + "discardButtonLabel", + "saveTooltip", + "imageEditColorBrightness", + "imageEditColorContrast", + "imageEditColorWhitePoint", + "imageEditColorBlackPoint", + "imageEditColorSaturation", + "imageEditColorWarmth", + "imageEditColorTint", + "imageEditTitle" ], "fr": [ @@ -291,7 +354,19 @@ "enhanceSuperResolution4xTitle", "enhanceStyleTransferTitle", "enhanceStyleTransferStyleDialogTitle", - "doubleTapExitNotification" + "doubleTapExitNotification", + "imageEditDiscardDialogTitle", + "imageEditDiscardDialogContent", + "discardButtonLabel", + "saveTooltip", + "imageEditColorBrightness", + "imageEditColorContrast", + "imageEditColorWhitePoint", + "imageEditColorBlackPoint", + "imageEditColorSaturation", + "imageEditColorWarmth", + "imageEditColorTint", + "imageEditTitle" ], "pl": [ @@ -343,7 +418,19 @@ "enhanceSuperResolution4xTitle", "enhanceStyleTransferTitle", "enhanceStyleTransferStyleDialogTitle", - "doubleTapExitNotification" + "doubleTapExitNotification", + "imageEditDiscardDialogTitle", + "imageEditDiscardDialogContent", + "discardButtonLabel", + "saveTooltip", + "imageEditColorBrightness", + "imageEditColorContrast", + "imageEditColorWhitePoint", + "imageEditColorBlackPoint", + "imageEditColorSaturation", + "imageEditColorWarmth", + "imageEditColorTint", + "imageEditTitle" ], "pt": [ @@ -374,7 +461,19 @@ "enhanceSuperResolution4xTitle", "enhanceStyleTransferTitle", "enhanceStyleTransferStyleDialogTitle", - "doubleTapExitNotification" + "doubleTapExitNotification", + "imageEditDiscardDialogTitle", + "imageEditDiscardDialogContent", + "discardButtonLabel", + "saveTooltip", + "imageEditColorBrightness", + "imageEditColorContrast", + "imageEditColorWhitePoint", + "imageEditColorBlackPoint", + "imageEditColorSaturation", + "imageEditColorWarmth", + "imageEditColorTint", + "imageEditTitle" ], "ru": [ @@ -405,7 +504,19 @@ "enhanceSuperResolution4xTitle", "enhanceStyleTransferTitle", "enhanceStyleTransferStyleDialogTitle", - "doubleTapExitNotification" + "doubleTapExitNotification", + "imageEditDiscardDialogTitle", + "imageEditDiscardDialogContent", + "discardButtonLabel", + "saveTooltip", + "imageEditColorBrightness", + "imageEditColorContrast", + "imageEditColorWhitePoint", + "imageEditColorBlackPoint", + "imageEditColorSaturation", + "imageEditColorWarmth", + "imageEditColorTint", + "imageEditTitle" ], "zh": [ @@ -436,7 +547,19 @@ "enhanceSuperResolution4xTitle", "enhanceStyleTransferTitle", "enhanceStyleTransferStyleDialogTitle", - "doubleTapExitNotification" + "doubleTapExitNotification", + "imageEditDiscardDialogTitle", + "imageEditDiscardDialogContent", + "discardButtonLabel", + "saveTooltip", + "imageEditColorBrightness", + "imageEditColorContrast", + "imageEditColorWhitePoint", + "imageEditColorBlackPoint", + "imageEditColorSaturation", + "imageEditColorWarmth", + "imageEditColorTint", + "imageEditTitle" ], "zh_Hant": [ @@ -467,6 +590,18 @@ "enhanceSuperResolution4xTitle", "enhanceStyleTransferTitle", "enhanceStyleTransferStyleDialogTitle", - "doubleTapExitNotification" + "doubleTapExitNotification", + "imageEditDiscardDialogTitle", + "imageEditDiscardDialogContent", + "discardButtonLabel", + "saveTooltip", + "imageEditColorBrightness", + "imageEditColorContrast", + "imageEditColorWhitePoint", + "imageEditColorBlackPoint", + "imageEditColorSaturation", + "imageEditColorWarmth", + "imageEditColorTint", + "imageEditTitle" ] } diff --git a/app/lib/theme.dart b/app/lib/theme.dart index b986b9da..f1ab184e 100644 --- a/app/lib/theme.dart +++ b/app/lib/theme.dart @@ -110,8 +110,8 @@ class AppTheme extends StatelessWidget { static Color getUnfocusedIconColor(BuildContext context) { return Theme.of(context).brightness == Brightness.light - ? Colors.black54 - : Colors.white70; + ? unfocusedIconColorLight + : unfocusedIconColorDark; } static ThemeData buildLightThemeData() { @@ -181,6 +181,9 @@ class AppTheme extends StatelessWidget { static const primaryTextColorLight = Colors.black87; static final primaryTextColorDark = Colors.white.withOpacity(.87); + static const unfocusedIconColorLight = Colors.black54; + static const unfocusedIconColorDark = Colors.white70; + static const widthLimitedContentMaxWidth = 550.0; /// Make a TextButton look like a default FlatButton. See diff --git a/app/lib/widget/image_editor.dart b/app/lib/widget/image_editor.dart new file mode 100644 index 00000000..ae936085 --- /dev/null +++ b/app/lib/widget/image_editor.dart @@ -0,0 +1,640 @@ +import 'package:flutter/material.dart'; +import 'package:nc_photos/account.dart'; +import 'package:nc_photos/api/api.dart'; +import 'package:nc_photos/api/api_util.dart' as api_util; +import 'package:nc_photos/app_localizations.dart'; +import 'package:nc_photos/cache_manager_util.dart'; +import 'package:nc_photos/double_extension.dart'; +import 'package:nc_photos/entity/file.dart'; +import 'package:nc_photos/iterable_extension.dart'; +import 'package:nc_photos/k.dart' as k; +import 'package:nc_photos/object_extension.dart'; +import 'package:nc_photos/pixel_image_provider.dart'; +import 'package:nc_photos/theme.dart'; +import 'package:nc_photos/widget/handler/permission_handler.dart'; +import 'package:nc_photos/widget/stateful_slider.dart'; +import 'package:nc_photos_plugin/nc_photos_plugin.dart'; + +class ImageEditorArguments { + const ImageEditorArguments(this.account, this.file); + + final Account account; + final File file; +} + +class ImageEditor extends StatefulWidget { + static const routeName = "/image-editor"; + + static Route buildRoute(ImageEditorArguments args) => MaterialPageRoute( + builder: (context) => ImageEditor.fromArgs(args), + ); + + const ImageEditor({ + Key? key, + required this.account, + required this.file, + }) : super(key: key); + + ImageEditor.fromArgs(ImageEditorArguments args, {Key? key}) + : this( + key: key, + account: args.account, + file: args.file, + ); + + @override + createState() => _ImageEditorState(); + + final Account account; + final File file; +} + +class _ImageEditorState extends State { + @override + initState() { + super.initState(); + _initImage(); + _ensurePermission(); + } + + Future _ensurePermission() async { + if (!await const PermissionHandler().ensureStorageWritePermission()) { + if (mounted) { + Navigator.of(context).pop(); + } + } + } + + @override + build(BuildContext context) => AppTheme( + child: Scaffold( + body: Builder( + builder: _buildContent, + ), + ), + ); + + Future _initImage() async { + final fileInfo = await LargeImageCacheManager.inst + .getFileFromCache(api_util.getFilePreviewUrl( + widget.account, + widget.file, + width: k.photoLargeSize, + height: k.photoLargeSize, + a: true, + )); + _src = await ImageLoader.loadUri( + "file://${fileInfo!.file.path}", + 480, + 360, + ImageLoaderResizeMethod.fit, + isAllowSwapSide: true, + ); + if (mounted) { + setState(() { + _isDoneInit = true; + }); + } + } + + Widget _buildContent(BuildContext context) { + return WillPopScope( + onWillPop: () async { + _onBackButton(context); + return false; + }, + child: ColoredBox( + color: Colors.black, + child: Column( + children: [ + _buildAppBar(context), + Expanded( + child: _isDoneInit + ? Image( + image: (_dst ?? _src).run((obj) => + PixelImage(obj.pixel, obj.width, obj.height)), + fit: BoxFit.contain, + gaplessPlayback: true, + ) + : Container(), + ), + _buildFilterOption(context), + _buildFilterBar(context), + ], + ), + ), + ); + } + + Widget _buildAppBar(BuildContext context) => AppBar( + backgroundColor: Colors.transparent, + shadowColor: Colors.transparent, + foregroundColor: Colors.white.withOpacity(.87), + leading: BackButton(onPressed: () => _onBackButton(context)), + title: Text(L10n.global().imageEditTitle), + actions: [ + if (_filters.isNotEmpty) + IconButton( + icon: const Icon(Icons.save_outlined), + tooltip: L10n.global().saveTooltip, + onPressed: () => _onSavePressed(context), + ), + ], + ); + + Widget _buildFilterBar(BuildContext context) { + return Align( + alignment: AlignmentDirectional.centerStart, + child: Material( + type: MaterialType.transparency, + child: SingleChildScrollView( + scrollDirection: Axis.horizontal, + child: Row( + children: [ + const SizedBox(width: 16), + _FilterButton( + icon: Icons.brightness_medium, + label: L10n.global().imageEditColorBrightness, + onPressed: _onBrightnessPressed, + isSelected: _selectedFilter == _ColorFilterType.brightness, + activationOrder: + _filters.keys.indexOf(_ColorFilterType.brightness), + ), + _FilterButton( + icon: Icons.contrast, + label: L10n.global().imageEditColorContrast, + onPressed: _onContrastPressed, + isSelected: _selectedFilter == _ColorFilterType.contrast, + activationOrder: + _filters.keys.indexOf(_ColorFilterType.contrast), + ), + _FilterButton( + icon: Icons.circle, + label: L10n.global().imageEditColorWhitePoint, + onPressed: _onWhitePointPressed, + isSelected: _selectedFilter == _ColorFilterType.whitePoint, + activationOrder: + _filters.keys.indexOf(_ColorFilterType.whitePoint), + ), + _FilterButton( + icon: Icons.circle_outlined, + label: L10n.global().imageEditColorBlackPoint, + onPressed: _onBlackPointPressed, + isSelected: _selectedFilter == _ColorFilterType.blackPoint, + activationOrder: + _filters.keys.indexOf(_ColorFilterType.blackPoint), + ), + _FilterButton( + icon: Icons.invert_colors, + label: L10n.global().imageEditColorSaturation, + onPressed: _onSaturationPressed, + isSelected: _selectedFilter == _ColorFilterType.saturation, + activationOrder: + _filters.keys.indexOf(_ColorFilterType.saturation), + ), + _FilterButton( + icon: Icons.thermostat, + label: L10n.global().imageEditColorWarmth, + onPressed: _onWarmthPressed, + isSelected: _selectedFilter == _ColorFilterType.warmth, + activationOrder: _filters.keys.indexOf(_ColorFilterType.warmth), + ), + _FilterButton( + icon: Icons.colorize, + label: L10n.global().imageEditColorTint, + onPressed: _onTintPressed, + isSelected: _selectedFilter == _ColorFilterType.tint, + activationOrder: _filters.keys.indexOf(_ColorFilterType.tint), + ), + const SizedBox(width: 16), + ], + ), + ), + ), + ); + } + + Widget _buildFilterOption(BuildContext context) { + Widget? child; + switch (_selectedFilter) { + case _ColorFilterType.brightness: + child = _buildBrightnessOption(context); + break; + + case _ColorFilterType.contrast: + child = _buildContrastOption(context); + break; + + case _ColorFilterType.whitePoint: + child = _buildWhitePointOption(context); + break; + + case _ColorFilterType.blackPoint: + child = _buildBlackPointOption(context); + break; + + case _ColorFilterType.saturation: + child = _buildSaturationOption(context); + break; + + case _ColorFilterType.warmth: + child = _buildWarmthOption(context); + break; + + case _ColorFilterType.tint: + child = _buildTintOption(context); + break; + + case null: + child = null; + break; + } + return Container( + height: 96, + alignment: Alignment.center, + child: child, + ); + } + + Widget _buildSliderOption( + BuildContext context, { + required Key key, + required double min, + required double max, + required double initialValue, + ValueChanged? onChangeEnd, + }) { + return AppTheme.dark( + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 16), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16), + child: Stack( + children: [ + Align( + alignment: AlignmentDirectional.centerStart, + child: Text(min.toStringAsFixedTruncated(1)), + ), + if (min < 0 && max > 0) + const Align( + alignment: AlignmentDirectional.center, + child: Text("0"), + ), + Align( + alignment: AlignmentDirectional.centerEnd, + child: Text(max.toStringAsFixedTruncated(1)), + ), + ], + ), + ), + StatefulSlider( + key: key, + initialValue: initialValue.toDouble(), + min: min.toDouble(), + max: max.toDouble(), + onChangeEnd: onChangeEnd, + ), + ], + ), + ), + ); + } + + Widget _buildBrightnessOption(BuildContext context) => _buildSliderOption( + context, + key: Key(_ColorFilterType.brightness.name), + min: -100, + max: 100, + initialValue: + (_filters[_ColorFilterType.brightness] as _BrightnessArguments) + .value, + onChangeEnd: (value) { + (_filters[_ColorFilterType.brightness] as _BrightnessArguments) + .value = value; + _applyFilters(); + }, + ); + + Widget _buildContrastOption(BuildContext context) => _buildSliderOption( + context, + key: Key(_ColorFilterType.contrast.name), + min: -100, + max: 100, + initialValue: + (_filters[_ColorFilterType.contrast] as _ContrastArguments).value, + onChangeEnd: (value) { + (_filters[_ColorFilterType.contrast] as _ContrastArguments).value = + value; + _applyFilters(); + }, + ); + + Widget _buildWhitePointOption(BuildContext context) => _buildSliderOption( + context, + key: Key(_ColorFilterType.whitePoint.name), + min: -100, + max: 100, + initialValue: + (_filters[_ColorFilterType.whitePoint] as _WhitePointArguments) + .value, + onChangeEnd: (value) { + (_filters[_ColorFilterType.whitePoint] as _WhitePointArguments) + .value = value; + _applyFilters(); + }, + ); + + Widget _buildBlackPointOption(BuildContext context) => _buildSliderOption( + context, + key: Key(_ColorFilterType.blackPoint.name), + min: -100, + max: 100, + initialValue: + (_filters[_ColorFilterType.blackPoint] as _BlackPointArguments) + .value, + onChangeEnd: (value) { + (_filters[_ColorFilterType.blackPoint] as _BlackPointArguments) + .value = value; + _applyFilters(); + }, + ); + + Widget _buildSaturationOption(BuildContext context) => _buildSliderOption( + context, + key: Key(_ColorFilterType.saturation.name), + min: -100, + max: 100, + initialValue: + (_filters[_ColorFilterType.saturation] as _SaturationArguments) + .value, + onChangeEnd: (value) { + (_filters[_ColorFilterType.saturation] as _SaturationArguments) + .value = value; + _applyFilters(); + }, + ); + + Widget _buildWarmthOption(BuildContext context) => _buildSliderOption( + context, + key: Key(_ColorFilterType.warmth.name), + min: -100, + max: 100, + initialValue: + (_filters[_ColorFilterType.warmth] as _WarmthArguments).value, + onChangeEnd: (value) { + (_filters[_ColorFilterType.warmth] as _WarmthArguments).value = value; + _applyFilters(); + }, + ); + + Widget _buildTintOption(BuildContext context) => _buildSliderOption( + context, + key: Key(_ColorFilterType.tint.name), + min: -100, + max: 100, + initialValue: (_filters[_ColorFilterType.tint] as _TintArguments).value, + onChangeEnd: (value) { + (_filters[_ColorFilterType.tint] as _TintArguments).value = value; + _applyFilters(); + }, + ); + + Future _onBackButton(BuildContext context) async { + if (_filters.isEmpty) { + Navigator.of(context).pop(); + return; + } + + final result = await showDialog( + context: context, + builder: (context) => AlertDialog( + title: Text(L10n.global().imageEditDiscardDialogTitle), + content: Text(L10n.global().imageEditDiscardDialogContent), + actions: [ + TextButton( + child: Text(MaterialLocalizations.of(context).cancelButtonLabel), + onPressed: () { + Navigator.of(context).pop(false); + }, + ), + TextButton( + child: Text(L10n.global().discardButtonLabel), + onPressed: () { + Navigator.of(context).pop(true); + }, + ), + ], + ), + ); + if (result == true) { + Navigator.of(context).pop(); + } + } + + Future _onSavePressed(BuildContext context) async { + await ImageProcessor.colorFilter( + "${widget.account.url}/${widget.file.path}", + widget.file.filename, + 4096, + 3072, + _buildFilterList(), + headers: { + "Authorization": Api.getAuthorizationHeaderValue(widget.account), + }, + ); + Navigator.of(context).pop(); + } + + void _onFilterPressed(_ColorFilterType type, _FilterArguments defArgs) { + if (_selectedFilter == type) { + // deactivate filter + setState(() { + _selectedFilter = null; + _filters.remove(type); + }); + } else { + setState(() { + _selectedFilter = type; + _filters[type] ??= defArgs; + }); + } + _applyFilters(); + } + + void _onBrightnessPressed() => + _onFilterPressed(_ColorFilterType.brightness, _BrightnessArguments(0)); + void _onContrastPressed() => + _onFilterPressed(_ColorFilterType.contrast, _ContrastArguments(0)); + void _onWhitePointPressed() => + _onFilterPressed(_ColorFilterType.whitePoint, _WhitePointArguments(0)); + void _onBlackPointPressed() => + _onFilterPressed(_ColorFilterType.blackPoint, _BlackPointArguments(0)); + void _onSaturationPressed() => + _onFilterPressed(_ColorFilterType.saturation, _SaturationArguments(0)); + void _onWarmthPressed() => + _onFilterPressed(_ColorFilterType.warmth, _WarmthArguments(0)); + void _onTintPressed() => + _onFilterPressed(_ColorFilterType.tint, _TintArguments(0)); + + List _buildFilterList() { + return _filters.entries.map((e) { + switch (e.key) { + case _ColorFilterType.brightness: + return (e.value as _BrightnessArguments) + .run((arg) => ColorBrightnessFilter(arg.value / 100)); + + case _ColorFilterType.contrast: + return (e.value as _ContrastArguments) + .run((arg) => ColorContrastFilter(arg.value / 100)); + + case _ColorFilterType.whitePoint: + return (e.value as _WhitePointArguments) + .run((arg) => ColorWhitePointFilter(arg.value / 100)); + + case _ColorFilterType.blackPoint: + return (e.value as _BlackPointArguments) + .run((arg) => ColorBlackPointFilter(arg.value / 100)); + + case _ColorFilterType.saturation: + return (e.value as _SaturationArguments) + .run((arg) => ColorSaturationFilter(arg.value / 100)); + + case _ColorFilterType.warmth: + return (e.value as _WarmthArguments) + .run((arg) => ColorWarmthFilter(arg.value / 100)); + + case _ColorFilterType.tint: + return (e.value as _TintArguments) + .run((arg) => ColorTintFilter(arg.value / 100)); + } + }).toList(); + } + + Future _applyFilters() async { + final result = await ImageProcessor.filterPreview(_src, _buildFilterList()); + setState(() { + _dst = result; + }); + } + + bool _isDoneInit = false; + late final Rgba8Image _src; + Rgba8Image? _dst; + final _filters = <_ColorFilterType, _FilterArguments>{}; + _ColorFilterType? _selectedFilter; +} + +enum _ColorFilterType { + brightness, + contrast, + whitePoint, + blackPoint, + saturation, + warmth, + tint, +} + +abstract class _FilterArguments {} + +class _FilterDoubleArguments implements _FilterArguments { + _FilterDoubleArguments(this.value); + + double value; +} + +typedef _BrightnessArguments = _FilterDoubleArguments; +typedef _ContrastArguments = _FilterDoubleArguments; +typedef _WhitePointArguments = _FilterDoubleArguments; +typedef _BlackPointArguments = _FilterDoubleArguments; +typedef _SaturationArguments = _FilterDoubleArguments; +typedef _WarmthArguments = _FilterDoubleArguments; +typedef _TintArguments = _FilterDoubleArguments; + +class _FilterButton extends StatelessWidget { + const _FilterButton({ + Key? key, + required this.icon, + required this.label, + required this.onPressed, + this.isSelected = false, + this.activationOrder = -1, + }) : super(key: key); + + @override + build(BuildContext context) { + final color = !isSelected && isActivated + ? AppTheme.primarySwatchDark[900]!.withOpacity(0.4) + : AppTheme.primarySwatchDark[500]!.withOpacity(0.7); + return InkWell( + onTap: onPressed, + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + SizedBox( + width: 64, + height: 64, + child: Stack( + fit: StackFit.expand, + children: [ + AnimatedOpacity( + opacity: isSelected || isActivated ? 1 : 0, + duration: k.animationDurationNormal, + child: Container( + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(32), + color: color, + ), + ), + ), + Center( + child: Icon( + icon, + size: 32, + color: AppTheme.unfocusedIconColorDark, + ), + ), + if (isActivated) + Padding( + padding: const EdgeInsets.only(top: 2), + child: Align( + alignment: Alignment.topCenter, + child: Text( + (activationOrder + 1).toString(), + style: const TextStyle( + fontSize: 12, + color: AppTheme.unfocusedIconColorDark, + ), + ), + ), + ), + ], + ), + ), + const SizedBox(height: 8), + Text( + label, + textAlign: TextAlign.center, + style: const TextStyle( + fontSize: 12, + color: AppTheme.unfocusedIconColorDark, + ), + ), + ], + ), + ), + ); + } + + bool get isActivated => activationOrder >= 0; + + final IconData icon; + final String label; + final VoidCallback? onPressed; + final bool isSelected; + final int activationOrder; +} diff --git a/app/lib/widget/my_app.dart b/app/lib/widget/my_app.dart index 8a883710..03ec447b 100644 --- a/app/lib/widget/my_app.dart +++ b/app/lib/widget/my_app.dart @@ -19,6 +19,7 @@ import 'package:nc_photos/widget/dynamic_album_browser.dart'; import 'package:nc_photos/widget/enhanced_photo_browser.dart'; import 'package:nc_photos/widget/favorite_browser.dart'; import 'package:nc_photos/widget/home.dart'; +import 'package:nc_photos/widget/image_editor.dart'; import 'package:nc_photos/widget/local_file_viewer.dart'; import 'package:nc_photos/widget/people_browser.dart'; import 'package:nc_photos/widget/person_browser.dart'; @@ -163,6 +164,7 @@ class _MyAppState extends State route ??= _handleEnhancedPhotoBrowserRoute(settings); route ??= _handleLocalFileViewerRoute(settings); route ??= _handleEnhancementSettingsRoute(settings); + route ??= _handleImageEditorRoute(settings); return route; } @@ -539,6 +541,19 @@ class _MyAppState extends State return null; } + Route? _handleImageEditorRoute(RouteSettings settings) { + try { + if (settings.name == ImageEditor.routeName && + settings.arguments != null) { + final args = settings.arguments as ImageEditorArguments; + return ImageEditor.buildRoute(args); + } + } catch (e) { + _log.severe("[_handleImageEditorRoute] Failed while handling route", e); + } + return null; + } + final _scaffoldMessengerKey = GlobalKey(); final _navigatorKey = GlobalKey(); diff --git a/app/lib/widget/viewer.dart b/app/lib/widget/viewer.dart index 7997e67c..35d8ea0b 100644 --- a/app/lib/widget/viewer.dart +++ b/app/lib/widget/viewer.dart @@ -26,6 +26,7 @@ import 'package:nc_photos/widget/disposable.dart'; import 'package:nc_photos/widget/handler/enhance_handler.dart'; import 'package:nc_photos/widget/handler/remove_selection_handler.dart'; import 'package:nc_photos/widget/horizontal_page_viewer.dart'; +import 'package:nc_photos/widget/image_editor.dart'; import 'package:nc_photos/widget/image_viewer.dart'; import 'package:nc_photos/widget/slideshow_dialog.dart'; import 'package:nc_photos/widget/slideshow_viewer.dart'; @@ -212,7 +213,15 @@ class _ViewerState extends State onPressed: () => _onSharePressed(context), ), if (features.isSupportEnhancement && - EnhanceHandler.isSupportedFormat(file)) + EnhanceHandler.isSupportedFormat(file)) ...[ + IconButton( + icon: Icon( + Icons.tune_outlined, + color: Colors.white.withOpacity(.87), + ), + tooltip: L10n.global().editTooltip, + onPressed: () => _onEditPressed(context), + ), IconButton( icon: Icon( Icons.auto_fix_high_outlined, @@ -221,6 +230,7 @@ class _ViewerState extends State tooltip: L10n.global().enhanceTooltip, onPressed: () => _onEnhancePressed(context), ), + ], IconButton( icon: Icon( Icons.download_outlined, @@ -570,6 +580,18 @@ class _ViewerState extends State ).shareFiles(widget.account, [file]); } + void _onEditPressed(BuildContext context) { + final file = widget.streamFiles[_viewerController.currentPage]; + if (!file_util.isSupportedImageFormat(file)) { + _log.shout("[_onEditPressed] Video file not supported"); + return; + } + + _log.info("[_onEditPressed] Edit file: ${file.path}"); + Navigator.of(context).pushNamed(ImageEditor.routeName, + arguments: ImageEditorArguments(widget.account, file)); + } + void _onEnhancePressed(BuildContext context) { final file = widget.streamFiles[_viewerController.currentPage]; if (!file_util.isSupportedImageFormat(file)) { diff --git a/plugin/android/src/main/cpp/CMakeLists.txt b/plugin/android/src/main/cpp/CMakeLists.txt index 6964cac1..432f59c1 100644 --- a/plugin/android/src/main/cpp/CMakeLists.txt +++ b/plugin/android/src/main/cpp/CMakeLists.txt @@ -33,6 +33,16 @@ add_library( # Sets the name of the library. SHARED # Provides a relative path to your source file(s). + filter/brightness.cpp + filter/color_levels.cpp + filter/contrast.cpp + filter/curve.cpp + filter/hslhsv.cpp + filter/saturation.cpp + filter/tint.cpp + filter/warmth.cpp + filter/yuv.cpp + lib/spline/spline.cpp arbitrary_style_transfer.cpp deep_lap_3.cpp esrgan.cpp diff --git a/plugin/android/src/main/cpp/filter/brightness.cpp b/plugin/android/src/main/cpp/filter/brightness.cpp new file mode 100644 index 00000000..e515c78e --- /dev/null +++ b/plugin/android/src/main/cpp/filter/brightness.cpp @@ -0,0 +1,77 @@ +#include +#include +#include +#include +#include +#include + +#include "../exception.h" +#include "../log.h" +#include "../math_util.h" +#include "../util.h" +#include "./hslhsv.h" + +using namespace plugin; +using namespace std; + +namespace { + +class Brightness { +public: + std::vector apply(const uint8_t *rgba8, const size_t width, + const size_t height, const float weight); + +private: + static constexpr const char *TAG = "Brightness"; +}; + +} // namespace + +extern "C" JNIEXPORT jbyteArray JNICALL +Java_com_nkming_nc_1photos_plugin_image_1processor_Brightness_applyNative( + JNIEnv *env, jobject *thiz, jbyteArray rgba8, jint width, jint height, + jfloat weight) { + try { + initOpenMp(); + RaiiContainer cRgba8( + [&]() { return env->GetByteArrayElements(rgba8, nullptr); }, + [&](jbyte *obj) { + env->ReleaseByteArrayElements(rgba8, obj, JNI_ABORT); + }); + const auto result = Brightness().apply( + reinterpret_cast(cRgba8.get()), width, height, weight); + auto resultAry = env->NewByteArray(result.size()); + env->SetByteArrayRegion(resultAry, 0, result.size(), + reinterpret_cast(result.data())); + return resultAry; + } catch (const exception &e) { + throwJavaException(env, e.what()); + return nullptr; + } +} + +namespace { + +vector Brightness::apply(const uint8_t *rgba8, const size_t width, + const size_t height, const float weight) { + LOGI(TAG, "[apply] weight: %.2f", weight); + if (weight == 0) { + // shortcut + return vector(rgba8, rgba8 + width * height * 4); + } + + const float mul = 1 + weight / 2; + vector output(width * height * 4); +#pragma omp parallel for + for (size_t i = 0; i < width * height; ++i) { + const auto p = i * 4; + auto hsv = filter::rgb8ToHsv(rgba8 + p); + hsv[2] = clamp(0.f, hsv[2] * mul, 1.f); + const auto &newRgb = filter::hsvToRgb8(hsv.data()); + memcpy(output.data() + p, newRgb.data(), 3); + output[p + 3] = rgba8[p + 3]; + } + return output; +} + +} // namespace diff --git a/plugin/android/src/main/cpp/filter/color_levels.cpp b/plugin/android/src/main/cpp/filter/color_levels.cpp new file mode 100644 index 00000000..da3873c4 --- /dev/null +++ b/plugin/android/src/main/cpp/filter/color_levels.cpp @@ -0,0 +1,178 @@ +#include +#include +#include +#include + +#include "../exception.h" +#include "../log.h" +#include "../math_util.h" +#include "../util.h" + +using namespace plugin; +using namespace std; + +namespace { + +constexpr float INPUT_AMPLITUDE = .4f; +constexpr uint8_t OUTPUT_AMPLITUDE = 100; + +class WhitePoint { +public: + std::vector apply(const uint8_t *rgba8, const size_t width, + const size_t height, const float weight); + +private: + static uint8_t applyInputLevel(const uint8_t p, const float weight) { + const auto pf = p / 255.f; + const auto max = 1 - weight * INPUT_AMPLITUDE; + return clamp(0, clamp(0.f, pf, max) / max * 255.f, 255); + } + + static uint8_t applyOutputLevel(const uint8_t p, const float weight) { + return clamp(0, p / 255.f * (255 - weight * OUTPUT_AMPLITUDE), 255); + } + + static std::vector buildLut(const float weight); + + static constexpr const char *TAG = "WhitePoint"; +}; + +class BlackPoint { +public: + std::vector apply(const uint8_t *rgba8, const size_t width, + const size_t height, const float weight); + +private: + static inline uint8_t applyInputLevel(const uint8_t p, const float weight) { + const auto pf = p / 255.f; + const auto min = weight * INPUT_AMPLITUDE; + return clamp(0, (clamp(min, pf, 1.f) - min) / (1 - min) * 255.f, 255); + } + + static inline uint8_t applyOutputLevel(const uint8_t p, const float weight) { + const auto x = weight * OUTPUT_AMPLITUDE; + return clamp(0, p / 255.f * (255 - x) + x, 255); + } + + static std::vector buildLut(const float weight); + + static constexpr const char *TAG = "BlackPoint"; +}; + +} // namespace + +extern "C" JNIEXPORT jbyteArray JNICALL +Java_com_nkming_nc_1photos_plugin_image_1processor_WhitePoint_applyNative( + JNIEnv *env, jobject *thiz, jbyteArray rgba8, jint width, jint height, + jfloat weight) { + try { + initOpenMp(); + RaiiContainer cRgba8( + [&]() { return env->GetByteArrayElements(rgba8, nullptr); }, + [&](jbyte *obj) { + env->ReleaseByteArrayElements(rgba8, obj, JNI_ABORT); + }); + const auto result = WhitePoint().apply( + reinterpret_cast(cRgba8.get()), width, height, weight); + auto resultAry = env->NewByteArray(result.size()); + env->SetByteArrayRegion(resultAry, 0, result.size(), + reinterpret_cast(result.data())); + return resultAry; + } catch (const exception &e) { + throwJavaException(env, e.what()); + return nullptr; + } +} + +extern "C" JNIEXPORT jbyteArray JNICALL +Java_com_nkming_nc_1photos_plugin_image_1processor_BlackPoint_applyNative( + JNIEnv *env, jobject *thiz, jbyteArray rgba8, jint width, jint height, + jfloat weight) { + try { + initOpenMp(); + RaiiContainer cRgba8( + [&]() { return env->GetByteArrayElements(rgba8, nullptr); }, + [&](jbyte *obj) { + env->ReleaseByteArrayElements(rgba8, obj, JNI_ABORT); + }); + const auto result = BlackPoint().apply( + reinterpret_cast(cRgba8.get()), width, height, weight); + auto resultAry = env->NewByteArray(result.size()); + env->SetByteArrayRegion(resultAry, 0, result.size(), + reinterpret_cast(result.data())); + return resultAry; + } catch (const exception &e) { + throwJavaException(env, e.what()); + return nullptr; + } +} + +namespace { + +vector WhitePoint::apply(const uint8_t *rgba8, const size_t width, + const size_t height, const float weight) { + LOGI(TAG, "[apply] weight: %.2f", weight); + if (weight == 0) { + // shortcut + return vector(rgba8, rgba8 + width * height * 4); + } + + const auto lut = buildLut(weight); + vector output(width * height * 4); +#pragma omp parallel for + for (size_t i = 0; i < width * height; ++i) { + const auto p = i * 4; + output[p + 0] = lut[rgba8[p + 0]]; + output[p + 1] = lut[rgba8[p + 1]]; + output[p + 2] = lut[rgba8[p + 2]]; + output[p + 3] = rgba8[p + 3]; + } + return output; +} + +vector WhitePoint::buildLut(const float weight) { + vector product(256); + const float weightAbs = std::abs(weight); + const auto fn = + weight > 0 ? &WhitePoint::applyInputLevel : &WhitePoint::applyOutputLevel; +#pragma omp parallel for + for (size_t i = 0; i < 256; ++i) { + product[i] = fn(i, weightAbs); + } + return product; +} + +vector BlackPoint::apply(const uint8_t *rgba8, const size_t width, + const size_t height, const float weight) { + LOGI(TAG, "[apply] weight: %.2f", weight); + if (weight == 0) { + // shortcut + return vector(rgba8, rgba8 + width * height * 4); + } + + const auto lut = buildLut(weight); + vector output(width * height * 4); +#pragma omp parallel for + for (size_t i = 0; i < width * height; ++i) { + const auto p = i * 4; + output[p + 0] = lut[rgba8[p + 0]]; + output[p + 1] = lut[rgba8[p + 1]]; + output[p + 2] = lut[rgba8[p + 2]]; + output[p + 3] = rgba8[p + 3]; + } + return output; +} + +vector BlackPoint::buildLut(const float weight) { + vector product(256); + const float weightAbs = std::abs(weight); + const auto fn = + weight > 0 ? &BlackPoint::applyInputLevel : &BlackPoint::applyOutputLevel; +#pragma omp parallel for + for (size_t i = 0; i < 256; ++i) { + product[i] = fn(i, weightAbs); + } + return product; +} + +} // namespace diff --git a/plugin/android/src/main/cpp/filter/contrast.cpp b/plugin/android/src/main/cpp/filter/contrast.cpp new file mode 100644 index 00000000..3eb820de --- /dev/null +++ b/plugin/android/src/main/cpp/filter/contrast.cpp @@ -0,0 +1,93 @@ +#include +#include +#include +#include +#include +#include +#include + +#include "../exception.h" +#include "../log.h" +#include "../math_util.h" +#include "../util.h" +#include "./hslhsv.h" + +using namespace plugin; +using namespace std; + +namespace { + +class Contrast { +public: + std::vector apply(const uint8_t *rgba8, const size_t width, + const size_t height, const float weight); + +private: + static constexpr const char *TAG = "Contrast"; +}; + +inline uint8_t applySingle(const uint8_t p, const float mul) { + return clamp(0, static_cast((p - 127) * mul + 127), 0xFF); +} + +std::vector buildLut(const float mul); + +} // namespace + +extern "C" JNIEXPORT jbyteArray JNICALL +Java_com_nkming_nc_1photos_plugin_image_1processor_Contrast_applyNative( + JNIEnv *env, jobject *thiz, jbyteArray rgba8, jint width, jint height, + jfloat weight) { + try { + initOpenMp(); + RaiiContainer cRgba8( + [&]() { return env->GetByteArrayElements(rgba8, nullptr); }, + [&](jbyte *obj) { + env->ReleaseByteArrayElements(rgba8, obj, JNI_ABORT); + }); + const auto result = Contrast().apply( + reinterpret_cast(cRgba8.get()), width, height, weight); + auto resultAry = env->NewByteArray(result.size()); + env->SetByteArrayRegion(resultAry, 0, result.size(), + reinterpret_cast(result.data())); + return resultAry; + } catch (const exception &e) { + throwJavaException(env, e.what()); + return nullptr; + } +} + +namespace { + +vector Contrast::apply(const uint8_t *rgba8, const size_t width, + const size_t height, const float weight) { + LOGI(TAG, "[apply] weight: %.2f", weight); + if (weight == 0) { + // shortcut + return vector(rgba8, rgba8 + width * height * 4); + } + + const float mul = weight >= 0 ? weight + 1 : (weight + 1) * .4f + .6f; + const auto lut = buildLut(mul); + vector output(width * height * 4); +#pragma omp parallel for + for (size_t i = 0; i < width * height; ++i) { + const auto p = i * 4; + output[p + 0] = lut[rgba8[p + 0]]; + output[p + 1] = lut[rgba8[p + 1]]; + output[p + 2] = lut[rgba8[p + 2]]; + output[p + 3] = rgba8[p + 3]; + } + return output; +} + +vector buildLut(const float mul) { + vector product(256); +#pragma omp parallel for + for (size_t i = 0; i < 256; ++i) { + product[i] = applySingle(i, mul); + } + return product; +} + +} // namespace diff --git a/plugin/android/src/main/cpp/filter/curve.cpp b/plugin/android/src/main/cpp/filter/curve.cpp new file mode 100644 index 00000000..3fae3dbb --- /dev/null +++ b/plugin/android/src/main/cpp/filter/curve.cpp @@ -0,0 +1,56 @@ +#include +#include +#include + +#include "../lib/spline/spline.h" +#include "./curve.h" + +using namespace std; + +namespace { + +std::vector buildLut(const vector &from, + const vector &to); +vector transformPoints(const vector &pts); + +} // namespace + +namespace plugin { +namespace filter { + +Curve::Curve(const vector &from, const vector &to) + : lut(buildLut(from, to)) {} + +} // namespace filter +} // namespace plugin + +namespace { + +std::vector buildLut(const vector &from, + const vector &to) { + assert(from.size() >= 2); + assert(from[0] == 0); + assert(from[from.size() - 1] == 255); + assert(to.size() >= 2); + assert(to[0] == 0); + assert(to[to.size() - 1] == 255); + tk::spline spline(transformPoints(from), transformPoints(to), + tk::spline::cspline_hermite); + std::vector lut; + lut.reserve(256); + for (int i = 0; i <= 0xFF; ++i) { + lut.push_back(std::min(std::max(0, static_cast(spline(i))), 0xFF)); + } + return lut; +} + +vector transformPoints(const vector &pts) { + vector product; + product.reserve(pts.size()); + for (const auto pt : pts) { + product.push_back(pt); + } + return product; +} + +} // namespace diff --git a/plugin/android/src/main/cpp/filter/curve.h b/plugin/android/src/main/cpp/filter/curve.h new file mode 100644 index 00000000..a9e6a59d --- /dev/null +++ b/plugin/android/src/main/cpp/filter/curve.h @@ -0,0 +1,26 @@ +#include +#include + +namespace plugin { +namespace filter { + +class Curve { +public: + /** + * Construct a curve that fit values from @a from to @a to + * @param from Control points, must be sorted, must begins with 0 and ends + * with 255 + * @param to + */ + Curve(const std::vector &from, const std::vector &to); + Curve(const Curve &) = default; + Curve(Curve &&) = default; + + uint8_t fit(const uint8_t from) const { return lut[from]; } + +private: + std::vector lut; +}; + +} // namespace filter +} // namespace plugin diff --git a/plugin/android/src/main/cpp/filter/hslhsv.cpp b/plugin/android/src/main/cpp/filter/hslhsv.cpp new file mode 100644 index 00000000..cda03bd8 --- /dev/null +++ b/plugin/android/src/main/cpp/filter/hslhsv.cpp @@ -0,0 +1,170 @@ +#include +#include +#include +#include + +#include "../math_util.h" +#include "./hslhsv.h" + +using namespace std; + +namespace plugin { +namespace filter { + +array rgb8ToHsl(const uint8_t *rgb8) { + const auto max = std::max(std::max(rgb8[0], rgb8[1]), rgb8[2]); + const auto min = std::min(std::min(rgb8[0], rgb8[1]), rgb8[2]); + const auto chroma = max - min; + + float rgbF[] = {rgb8[0] / 255.f, rgb8[1] / 255.f, rgb8[2] / 255.f}; + const auto maxF = max / 255.f; + const auto minF = min / 255.f; + const auto chromaF = maxF - minF; + + const auto l = (maxF + minF) / 2; + float h; + if (chroma == 0) { + h = 0; + } else if (max == rgb8[0]) { + h = fmodf(60 * (0 + (rgbF[1] - rgbF[2]) / chromaF) + 360, 360.f); + } else if (max == rgb8[1]) { + h = 60 * (2 + (rgbF[2] - rgbF[0]) / chromaF); + } else if (max == rgb8[2]) { + h = 60 * (4 + (rgbF[0] - rgbF[1]) / chromaF); + } + float s; + if (std::abs(l - 0) < 1e-3 || std::abs(l - 1) < 1e-3) { + s = 0; + } else { + s = chromaF / (1 - std::abs(2 * maxF - chromaF - 1)); + } + return {h, s, l}; +} + +array rgb8ToHsv(const uint8_t *rgb8) { + const auto max = std::max(std::max(rgb8[0], rgb8[1]), rgb8[2]); + const auto min = std::min(std::min(rgb8[0], rgb8[1]), rgb8[2]); + const auto chroma = max - min; + + float rgbF[] = {rgb8[0] / 255.f, rgb8[1] / 255.f, rgb8[2] / 255.f}; + const auto maxF = max / 255.f; + const auto minF = min / 255.f; + const auto chromaF = maxF - minF; + + float h; + if (chroma == 0) { + h = 0; + } else if (max == rgb8[0]) { + h = fmodf(60 * (0 + (rgbF[1] - rgbF[2]) / chromaF) + 360, 360.f); + } else if (max == rgb8[1]) { + h = 60 * (2 + (rgbF[2] - rgbF[0]) / chromaF); + } else if (max == rgb8[2]) { + h = 60 * (4 + (rgbF[0] - rgbF[1]) / chromaF); + } + float s; + if (max == 0) { + s = 0; + } else { + s = chromaF / maxF; + } + return {h, s, maxF}; +} + +array hslToRgb8(const float *hsl) { + const auto chroma = hsl[1] * (1 - std::abs(2 * hsl[2] - 1)); + const auto h2 = hsl[0] / 60; + const auto x = chroma * (1 - std::abs(fmodf(h2, 2) - 1)); + float r2, g2, b2; + if (0 <= h2 && h2 < 1) { + r2 = chroma; + g2 = x; + b2 = 0; + } else if (1 <= h2 && h2 < 2) { + r2 = x; + g2 = chroma; + b2 = 0; + } else if (2 <= h2 && h2 < 3) { + r2 = 0; + g2 = chroma; + b2 = x; + } else if (3 <= h2 && h2 < 4) { + r2 = 0; + g2 = x; + b2 = chroma; + } else if (4 <= h2 && h2 < 5) { + r2 = x; + g2 = 0; + b2 = chroma; + } else { + // 5 <= h2 && h2 < 6 + r2 = chroma; + g2 = 0; + b2 = x; + } + const auto m = hsl[2] - chroma / 2; + return {static_cast((r2 + m) * 255), + static_cast((g2 + m) * 255), + static_cast((b2 + m) * 255)}; +} + +array hsvToRgb8(const float *hsv) { + const auto chroma = hsv[2] * hsv[1]; + const auto h2 = hsv[0] / 60; + const auto x = chroma * (1 - std::abs(fmodf(h2, 2) - 1)); + float r2, g2, b2; + if (0 <= h2 && h2 < 1) { + r2 = chroma; + g2 = x; + b2 = 0; + } else if (1 <= h2 && h2 < 2) { + r2 = x; + g2 = chroma; + b2 = 0; + } else if (2 <= h2 && h2 < 3) { + r2 = 0; + g2 = chroma; + b2 = x; + } else if (3 <= h2 && h2 < 4) { + r2 = 0; + g2 = x; + b2 = chroma; + } else if (4 <= h2 && h2 < 5) { + r2 = x; + g2 = 0; + b2 = chroma; + } else { + // 5 <= h2 && h2 < 6 + r2 = chroma; + g2 = 0; + b2 = x; + } + const auto m = hsv[2] - chroma; + return {static_cast((r2 + m) * 255), + static_cast((g2 + m) * 255), + static_cast((b2 + m) * 255)}; +} + +std::array hslToHsv(const float *hsl) { + const auto v = hsl[2] + hsl[1] * std::min(hsl[2], 1 - hsl[2]); + float s; + if (std::abs(v - 0) < 1e-3) { + s = 0; + } else { + s = 2 * (1 - hsl[2] / v); + } + return {hsl[0], s, v}; +} + +std::array hsvToHsl(const float *hsv) { + const auto l = hsv[2] * (1 - hsv[1] / 2); + float s; + if (std::abs(l - 0) < 1e-3 || std::abs(l - 1) < 1e-3) { + s = 0; + } else { + s = (hsv[2] - l) / std::min(l, 1 - l); + } + return {hsv[0], s, l}; +} + +} // namespace filter +} // namespace plugin diff --git a/plugin/android/src/main/cpp/filter/hslhsv.h b/plugin/android/src/main/cpp/filter/hslhsv.h new file mode 100644 index 00000000..07d71cdf --- /dev/null +++ b/plugin/android/src/main/cpp/filter/hslhsv.h @@ -0,0 +1,19 @@ +#pragma once + +#include +#include + +namespace plugin { +namespace filter { + +std::array rgb8ToHsl(const uint8_t *rgb8); +std::array rgb8ToHsv(const uint8_t *rgb8); + +std::array hslToRgb8(const float *hsl); +std::array hsvToRgb8(const float *hsv); + +std::array hslToHsv(const float *hsl); +std::array hsvToHsl(const float *hsv); + +} // namespace filter +} // namespace plugin diff --git a/plugin/android/src/main/cpp/filter/saturation.cpp b/plugin/android/src/main/cpp/filter/saturation.cpp new file mode 100644 index 00000000..3e95f408 --- /dev/null +++ b/plugin/android/src/main/cpp/filter/saturation.cpp @@ -0,0 +1,77 @@ +#include +#include +#include +#include +#include +#include +#include + +#include "../exception.h" +#include "../log.h" +#include "../math_util.h" +#include "../util.h" +#include "./hslhsv.h" + +using namespace plugin; +using namespace std; + +namespace { + +class Saturation { +public: + std::vector apply(const uint8_t *rgba8, const size_t width, + const size_t height, const float weight); + +private: + static constexpr const char *TAG = "Saturation"; +}; + +} // namespace + +extern "C" JNIEXPORT jbyteArray JNICALL +Java_com_nkming_nc_1photos_plugin_image_1processor_Saturation_applyNative( + JNIEnv *env, jobject *thiz, jbyteArray rgba8, jint width, jint height, + jfloat value) { + try { + initOpenMp(); + RaiiContainer cRgba8( + [&]() { return env->GetByteArrayElements(rgba8, nullptr); }, + [&](jbyte *obj) { + env->ReleaseByteArrayElements(rgba8, obj, JNI_ABORT); + }); + const auto result = Saturation().apply( + reinterpret_cast(cRgba8.get()), width, height, value); + auto resultAry = env->NewByteArray(result.size()); + env->SetByteArrayRegion(resultAry, 0, result.size(), + reinterpret_cast(result.data())); + return resultAry; + } catch (const exception &e) { + throwJavaException(env, e.what()); + return nullptr; + } +} + +namespace { + +vector Saturation::apply(const uint8_t *rgba8, const size_t width, + const size_t height, const float weight) { + LOGI(TAG, "[apply] weight: %.2f", weight); + if (weight == 0) { + // shortcut + return vector(rgba8, rgba8 + width * height * 4); + } + + vector output(width * height * 4); +#pragma omp parallel for + for (size_t i = 0; i < width * height; ++i) { + const auto p = i * 4; + auto hsl = filter::rgb8ToHsl(rgba8 + p); + hsl[1] = clamp(0.f, hsl[1] * (1 + weight), 1.f); + const auto &newRgb = filter::hslToRgb8(hsl.data()); + memcpy(output.data() + p, newRgb.data(), 3); + output[p + 3] = rgba8[p + 3]; + } + return output; +} + +} // namespace diff --git a/plugin/android/src/main/cpp/filter/tint.cpp b/plugin/android/src/main/cpp/filter/tint.cpp new file mode 100644 index 00000000..447fca2b --- /dev/null +++ b/plugin/android/src/main/cpp/filter/tint.cpp @@ -0,0 +1,77 @@ +#include +#include +#include +#include +#include + +#include "../exception.h" +#include "../log.h" +#include "../math_util.h" +#include "../util.h" +#include "./yuv.h" + +using namespace plugin; +using namespace std; + +namespace { + +class Tint { +public: + std::vector apply(const uint8_t *rgba8, const size_t width, + const size_t height, const float weight); + +private: + static constexpr const char *TAG = "Tint"; +}; + +} // namespace + +extern "C" JNIEXPORT jbyteArray JNICALL +Java_com_nkming_nc_1photos_plugin_image_1processor_Tint_applyNative( + JNIEnv *env, jobject *thiz, jbyteArray rgba8, jint width, jint height, + jfloat weight) { + try { + initOpenMp(); + RaiiContainer cRgba8( + [&]() { return env->GetByteArrayElements(rgba8, nullptr); }, + [&](jbyte *obj) { + env->ReleaseByteArrayElements(rgba8, obj, JNI_ABORT); + }); + const auto result = Tint().apply(reinterpret_cast(cRgba8.get()), + width, height, weight); + auto resultAry = env->NewByteArray(result.size()); + env->SetByteArrayRegion(resultAry, 0, result.size(), + reinterpret_cast(result.data())); + return resultAry; + } catch (const exception &e) { + throwJavaException(env, e.what()); + return nullptr; + } +} + +namespace { + +vector Tint::apply(const uint8_t *rgba8, const size_t width, + const size_t height, const float weight) { + LOGI(TAG, "[apply] weight: %.2f", weight); + if (weight == 0) { + // shortcut + return vector(rgba8, rgba8 + width * height * 4); + } + + vector output(width * height * 4); +#pragma omp parallel for + for (size_t i = 0; i < width * height; ++i) { + const auto p = i * 4; + auto yuv = filter::rgb8ToYuv(rgba8 + p); + // +-0.1 + yuv[1] = clamp(0.f, yuv[1] + 0.1f * weight, 1.f); + yuv[2] = clamp(0.f, yuv[2] + 0.1f * weight, 1.f); + const auto &newRgb = filter::yuvToRgb8(yuv.data()); + memcpy(output.data() + p, newRgb.data(), 3); + output[p + 3] = rgba8[p + 3]; + } + return output; +} + +} // namespace diff --git a/plugin/android/src/main/cpp/filter/warmth.cpp b/plugin/android/src/main/cpp/filter/warmth.cpp new file mode 100644 index 00000000..a3ae7af2 --- /dev/null +++ b/plugin/android/src/main/cpp/filter/warmth.cpp @@ -0,0 +1,118 @@ +#include +#include +#include +#include +#include +#include + +#include "../exception.h" +#include "../log.h" +#include "../util.h" +#include "./curve.h" + +using namespace plugin; +using namespace std; + +namespace { + +class Warmth { +public: + std::vector apply(const uint8_t *rgba8, const size_t width, + const size_t height, const float weight); + +private: + static filter::Curve getRCurve(const float weight); + static unique_ptr getGCurve(const float weight); + static filter::Curve getBCurve(const float weight); + + static constexpr const char *TAG = "Warmth"; +}; + +inline uint8_t weighted(const uint8_t from, const uint8_t to, + const float weight) { + return (to - from) * weight + from; +} + +} // namespace + +extern "C" JNIEXPORT jbyteArray JNICALL +Java_com_nkming_nc_1photos_plugin_image_1processor_Warmth_applyNative( + JNIEnv *env, jobject *thiz, jbyteArray rgba8, jint width, jint height, + jfloat weight) { + try { + initOpenMp(); + RaiiContainer cRgba8( + [&]() { return env->GetByteArrayElements(rgba8, nullptr); }, + [&](jbyte *obj) { + env->ReleaseByteArrayElements(rgba8, obj, JNI_ABORT); + }); + const auto result = Warmth().apply( + reinterpret_cast(cRgba8.get()), width, height, weight); + auto resultAry = env->NewByteArray(result.size()); + env->SetByteArrayRegion(resultAry, 0, result.size(), + reinterpret_cast(result.data())); + return resultAry; + } catch (const exception &e) { + throwJavaException(env, e.what()); + return nullptr; + } +} + +namespace { + +vector Warmth::apply(const uint8_t *rgba8, const size_t width, + const size_t height, const float weight) { + LOGI(TAG, "[apply] weight: %.2f", weight); + if (weight == 0) { + // shortcut + return vector(rgba8, rgba8 + width * height * 4); + } + const auto rCurve = getRCurve(weight); + const auto gCurve = getGCurve(weight); + const auto bCurve = getBCurve(weight); + + vector output(width * height * 4); +#pragma omp parallel for + for (size_t i = 0; i < width * height; ++i) { + const auto p = i * 4; + output[p + 0] = rCurve.fit(rgba8[p + 0]); + output[p + 1] = gCurve ? gCurve->fit(rgba8[p + 1]) : rgba8[p + 1]; + output[p + 2] = bCurve.fit(rgba8[p + 2]); + output[p + 3] = rgba8[p + 3]; + } + return output; +} + +filter::Curve Warmth::getRCurve(const float weight) { + if (weight >= 0) { + return filter::Curve({0, 78, 195, 255}, {0, weighted(78, 100, weight), + weighted(195, 220, weight), 255}); + } else { + return filter::Curve({0, 95, 220, 255}, + {0, weighted(95, 60, std::abs(weight)), + weighted(220, 185, std::abs(weight)), 255}); + } +} + +unique_ptr Warmth::getGCurve(const float weight) { + if (weight >= 0) { + return make_unique( + vector{0, 135, 255}, + vector{0, weighted(135, 125, weight), 255}); + } else { + return nullptr; + } +} + +filter::Curve Warmth::getBCurve(const float weight) { + if (weight >= 0) { + return filter::Curve({0, 95, 220, 255}, {0, weighted(95, 60, weight), + weighted(220, 185, weight), 255}); + } else { + return filter::Curve({0, 78, 195, 255}, + {0, weighted(78, 100, std::abs(weight)), + weighted(195, 220, std::abs(weight)), 255}); + } +} + +} // namespace diff --git a/plugin/android/src/main/cpp/filter/yuv.cpp b/plugin/android/src/main/cpp/filter/yuv.cpp new file mode 100644 index 00000000..aec8047a --- /dev/null +++ b/plugin/android/src/main/cpp/filter/yuv.cpp @@ -0,0 +1,40 @@ +#include +#include + +#include "../math_util.h" +#include "./yuv.h" + +using namespace std; + +namespace plugin { +namespace filter { + +array rgb8ToYuv(const uint8_t *rgb8) { + const float rgbF[] = {rgb8[0] / 255.f, rgb8[1] / 255.f, rgb8[2] / 255.f}; + return { + clamp(0.f, rgbF[0] * .299f + rgbF[1] * .587f + rgbF[2] * .114f, 1.f), + clamp(0.f, + rgbF[0] * -.168736f + rgbF[1] * -.331264f + rgbF[2] * .5f + .5f, + 1.f), + clamp(0.f, + rgbF[0] * .5f + rgbF[1] * -.418688f + rgbF[2] * -.081312f + .5f, + 1.f), + }; +} + +array yuvToRgb8(const float *yuv) { + const float yuv_[] = {yuv[0], yuv[1] - .5f, yuv[2] - .5f}; + const float rgbF[] = { + yuv_[0] + yuv_[2] * 1.4f, + yuv_[0] + yuv_[1] * -.343f + yuv_[2] * -.711f, + yuv_[0] + yuv_[1] * 1.765f, + }; + return { + static_cast(clamp(0, rgbF[0] * 255, 255)), + static_cast(clamp(0, rgbF[1] * 255, 255)), + static_cast(clamp(0, rgbF[2] * 255, 255)), + }; +} + +} // namespace filter +} // namespace plugin diff --git a/plugin/android/src/main/cpp/filter/yuv.h b/plugin/android/src/main/cpp/filter/yuv.h new file mode 100644 index 00000000..0cd99946 --- /dev/null +++ b/plugin/android/src/main/cpp/filter/yuv.h @@ -0,0 +1,26 @@ +#pragma once + +#include +#include + +namespace plugin { +namespace filter { + +/** + * Map a RGB color to full range YCbCr + * + * @param rgb8 + * @return Returned values are in the range of 0 to 1 + */ +std::array rgb8ToYuv(const uint8_t *rgb8); + +/** + * Map a full range YCbCr color to RGB + * + * @param yuv + * @return + */ +std::array yuvToRgb8(const float *yuv); + +} // namespace filter +} // namespace plugin diff --git a/plugin/android/src/main/cpp/lib/spline/LICENSE b/plugin/android/src/main/cpp/lib/spline/LICENSE new file mode 100644 index 00000000..8cdb8451 --- /dev/null +++ b/plugin/android/src/main/cpp/lib/spline/LICENSE @@ -0,0 +1,340 @@ + GNU GENERAL PUBLIC LICENSE + Version 2, June 1991 + + Copyright (C) 1989, 1991 Free Software Foundation, Inc., + 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA + Everyone is permitted to copy and distribute verbatim copies + of this license document, but changing it is not allowed. + + Preamble + + The licenses for most software are designed to take away your +freedom to share and change it. By contrast, the GNU General Public +License is intended to guarantee your freedom to share and change free +software--to make sure the software is free for all its users. This +General Public License applies to most of the Free Software +Foundation's software and to any other program whose authors commit to +using it. (Some other Free Software Foundation software is covered by +the GNU Lesser General Public License instead.) You can apply it to +your programs, too. + + When we speak of free software, we are referring to freedom, not +price. Our General Public Licenses are designed to make sure that you +have the freedom to distribute copies of free software (and charge for +this service if you wish), that you receive source code or can get it +if you want it, that you can change the software or use pieces of it +in new free programs; and that you know you can do these things. + + To protect your rights, we need to make restrictions that forbid +anyone to deny you these rights or to ask you to surrender the rights. +These restrictions translate to certain responsibilities for you if you +distribute copies of the software, or if you modify it. + + For example, if you distribute copies of such a program, whether +gratis or for a fee, you must give the recipients all the rights that +you have. You must make sure that they, too, receive or can get the +source code. And you must show them these terms so they know their +rights. + + We protect your rights with two steps: (1) copyright the software, and +(2) offer you this license which gives you legal permission to copy, +distribute and/or modify the software. + + Also, for each author's protection and ours, we want to make certain +that everyone understands that there is no warranty for this free +software. If the software is modified by someone else and passed on, we +want its recipients to know that what they have is not the original, so +that any problems introduced by others will not reflect on the original +authors' reputations. + + Finally, any free program is threatened constantly by software +patents. We wish to avoid the danger that redistributors of a free +program will individually obtain patent licenses, in effect making the +program proprietary. To prevent this, we have made it clear that any +patent must be licensed for everyone's free use or not licensed at all. + + The precise terms and conditions for copying, distribution and +modification follow. + + GNU GENERAL PUBLIC LICENSE + TERMS AND CONDITIONS FOR COPYING, DISTRIBUTION AND MODIFICATION + + 0. This License applies to any program or other work which contains +a notice placed by the copyright holder saying it may be distributed +under the terms of this General Public License. The "Program", below, +refers to any such program or work, and a "work based on the Program" +means either the Program or any derivative work under copyright law: +that is to say, a work containing the Program or a portion of it, +either verbatim or with modifications and/or translated into another +language. (Hereinafter, translation is included without limitation in +the term "modification".) Each licensee is addressed as "you". + +Activities other than copying, distribution and modification are not +covered by this License; they are outside its scope. The act of +running the Program is not restricted, and the output from the Program +is covered only if its contents constitute a work based on the +Program (independent of having been made by running the Program). +Whether that is true depends on what the Program does. + + 1. You may copy and distribute verbatim copies of the Program's +source code as you receive it, in any medium, provided that you +conspicuously and appropriately publish on each copy an appropriate +copyright notice and disclaimer of warranty; keep intact all the +notices that refer to this License and to the absence of any warranty; +and give any other recipients of the Program a copy of this License +along with the Program. + +You may charge a fee for the physical act of transferring a copy, and +you may at your option offer warranty protection in exchange for a fee. + + 2. You may modify your copy or copies of the Program or any portion +of it, thus forming a work based on the Program, and copy and +distribute such modifications or work under the terms of Section 1 +above, provided that you also meet all of these conditions: + + a) You must cause the modified files to carry prominent notices + stating that you changed the files and the date of any change. + + b) You must cause any work that you distribute or publish, that in + whole or in part contains or is derived from the Program or any + part thereof, to be licensed as a whole at no charge to all third + parties under the terms of this License. + + c) If the modified program normally reads commands interactively + when run, you must cause it, when started running for such + interactive use in the most ordinary way, to print or display an + announcement including an appropriate copyright notice and a + notice that there is no warranty (or else, saying that you provide + a warranty) and that users may redistribute the program under + these conditions, and telling the user how to view a copy of this + License. (Exception: if the Program itself is interactive but + does not normally print such an announcement, your work based on + the Program is not required to print an announcement.) + +These requirements apply to the modified work as a whole. If +identifiable sections of that work are not derived from the Program, +and can be reasonably considered independent and separate works in +themselves, then this License, and its terms, do not apply to those +sections when you distribute them as separate works. But when you +distribute the same sections as part of a whole which is a work based +on the Program, the distribution of the whole must be on the terms of +this License, whose permissions for other licensees extend to the +entire whole, and thus to each and every part regardless of who wrote it. + +Thus, it is not the intent of this section to claim rights or contest +your rights to work written entirely by you; rather, the intent is to +exercise the right to control the distribution of derivative or +collective works based on the Program. + +In addition, mere aggregation of another work not based on the Program +with the Program (or with a work based on the Program) on a volume of +a storage or distribution medium does not bring the other work under +the scope of this License. + + 3. You may copy and distribute the Program (or a work based on it, +under Section 2) in object code or executable form under the terms of +Sections 1 and 2 above provided that you also do one of the following: + + a) Accompany it with the complete corresponding machine-readable + source code, which must be distributed under the terms of Sections + 1 and 2 above on a medium customarily used for software interchange; or, + + b) Accompany it with a written offer, valid for at least three + years, to give any third party, for a charge no more than your + cost of physically performing source distribution, a complete + machine-readable copy of the corresponding source code, to be + distributed under the terms of Sections 1 and 2 above on a medium + customarily used for software interchange; or, + + c) Accompany it with the information you received as to the offer + to distribute corresponding source code. (This alternative is + allowed only for noncommercial distribution and only if you + received the program in object code or executable form with such + an offer, in accord with Subsection b above.) + +The source code for a work means the preferred form of the work for +making modifications to it. For an executable work, complete source +code means all the source code for all modules it contains, plus any +associated interface definition files, plus the scripts used to +control compilation and installation of the executable. However, as a +special exception, the source code distributed need not include +anything that is normally distributed (in either source or binary +form) with the major components (compiler, kernel, and so on) of the +operating system on which the executable runs, unless that component +itself accompanies the executable. + +If distribution of executable or object code is made by offering +access to copy from a designated place, then offering equivalent +access to copy the source code from the same place counts as +distribution of the source code, even though third parties are not +compelled to copy the source along with the object code. + + 4. You may not copy, modify, sublicense, or distribute the Program +except as expressly provided under this License. Any attempt +otherwise to copy, modify, sublicense or distribute the Program is +void, and will automatically terminate your rights under this License. +However, parties who have received copies, or rights, from you under +this License will not have their licenses terminated so long as such +parties remain in full compliance. + + 5. You are not required to accept this License, since you have not +signed it. However, nothing else grants you permission to modify or +distribute the Program or its derivative works. These actions are +prohibited by law if you do not accept this License. Therefore, by +modifying or distributing the Program (or any work based on the +Program), you indicate your acceptance of this License to do so, and +all its terms and conditions for copying, distributing or modifying +the Program or works based on it. + + 6. Each time you redistribute the Program (or any work based on the +Program), the recipient automatically receives a license from the +original licensor to copy, distribute or modify the Program subject to +these terms and conditions. You may not impose any further +restrictions on the recipients' exercise of the rights granted herein. +You are not responsible for enforcing compliance by third parties to +this License. + + 7. If, as a consequence of a court judgment or allegation of patent +infringement or for any other reason (not limited to patent issues), +conditions are imposed on you (whether by court order, agreement or +otherwise) that contradict the conditions of this License, they do not +excuse you from the conditions of this License. If you cannot +distribute so as to satisfy simultaneously your obligations under this +License and any other pertinent obligations, then as a consequence you +may not distribute the Program at all. For example, if a patent +license would not permit royalty-free redistribution of the Program by +all those who receive copies directly or indirectly through you, then +the only way you could satisfy both it and this License would be to +refrain entirely from distribution of the Program. + +If any portion of this section is held invalid or unenforceable under +any particular circumstance, the balance of the section is intended to +apply and the section as a whole is intended to apply in other +circumstances. + +It is not the purpose of this section to induce you to infringe any +patents or other property right claims or to contest validity of any +such claims; this section has the sole purpose of protecting the +integrity of the free software distribution system, which is +implemented by public license practices. Many people have made +generous contributions to the wide range of software distributed +through that system in reliance on consistent application of that +system; it is up to the author/donor to decide if he or she is willing +to distribute software through any other system and a licensee cannot +impose that choice. + +This section is intended to make thoroughly clear what is believed to +be a consequence of the rest of this License. + + 8. If the distribution and/or use of the Program is restricted in +certain countries either by patents or by copyrighted interfaces, the +original copyright holder who places the Program under this License +may add an explicit geographical distribution limitation excluding +those countries, so that distribution is permitted only in or among +countries not thus excluded. In such case, this License incorporates +the limitation as if written in the body of this License. + + 9. The Free Software Foundation may publish revised and/or new versions +of the General Public License from time to time. Such new versions will +be similar in spirit to the present version, but may differ in detail to +address new problems or concerns. + +Each version is given a distinguishing version number. If the Program +specifies a version number of this License which applies to it and "any +later version", you have the option of following the terms and conditions +either of that version or of any later version published by the Free +Software Foundation. If the Program does not specify a version number of +this License, you may choose any version ever published by the Free Software +Foundation. + + 10. If you wish to incorporate parts of the Program into other free +programs whose distribution conditions are different, write to the author +to ask for permission. For software which is copyrighted by the Free +Software Foundation, write to the Free Software Foundation; we sometimes +make exceptions for this. Our decision will be guided by the two goals +of preserving the free status of all derivatives of our free software and +of promoting the sharing and reuse of software generally. + + NO WARRANTY + + 11. BECAUSE THE PROGRAM IS LICENSED FREE OF CHARGE, THERE IS NO WARRANTY +FOR THE PROGRAM, TO THE EXTENT PERMITTED BY APPLICABLE LAW. EXCEPT WHEN +OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR OTHER PARTIES +PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY OF ANY KIND, EITHER EXPRESSED +OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF +MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE. THE ENTIRE RISK AS +TO THE QUALITY AND PERFORMANCE OF THE PROGRAM IS WITH YOU. SHOULD THE +PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF ALL NECESSARY SERVICING, +REPAIR OR CORRECTION. + + 12. IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING +WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MAY MODIFY AND/OR +REDISTRIBUTE THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, +INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING +OUT OF THE USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED +TO LOSS OF DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY +YOU OR THIRD PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER +PROGRAMS), EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE +POSSIBILITY OF SUCH DAMAGES. + + END OF TERMS AND CONDITIONS + + How to Apply These Terms to Your New Programs + + If you develop a new program, and you want it to be of the greatest +possible use to the public, the best way to achieve this is to make it +free software which everyone can redistribute and change under these terms. + + To do so, attach the following notices to the program. It is safest +to attach them to the start of each source file to most effectively +convey the exclusion of warranty; and each file should have at least +the "copyright" line and a pointer to where the full notice is found. + + {description} + Copyright (C) {year} {fullname} + + This program is free software; you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation; either version 2 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License along + with this program; if not, write to the Free Software Foundation, Inc., + 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. + +Also add information on how to contact you by electronic and paper mail. + +If the program is interactive, make it output a short notice like this +when it starts in an interactive mode: + + Gnomovision version 69, Copyright (C) year name of author + Gnomovision comes with ABSOLUTELY NO WARRANTY; for details type `show w'. + This is free software, and you are welcome to redistribute it + under certain conditions; type `show c' for details. + +The hypothetical commands `show w' and `show c' should show the appropriate +parts of the General Public License. Of course, the commands you use may +be called something other than `show w' and `show c'; they could even be +mouse-clicks or menu items--whatever suits your program. + +You should also get your employer (if you work as a programmer) or your +school, if any, to sign a "copyright disclaimer" for the program, if +necessary. Here is a sample; alter the names: + + Yoyodyne, Inc., hereby disclaims all copyright interest in the program + `Gnomovision' (which makes passes at compilers) written by James Hacker. + + {signature of Ty Coon}, 1 April 1989 + Ty Coon, President of Vice + +This General Public License does not permit incorporating your program into +proprietary programs. If your program is a subroutine library, you may +consider it more useful to permit linking proprietary applications with the +library. If this is what you want to do, use the GNU Lesser General +Public License instead of this License. + diff --git a/plugin/android/src/main/cpp/lib/spline/spline.cpp b/plugin/android/src/main/cpp/lib/spline/spline.cpp new file mode 100644 index 00000000..eb93d4eb --- /dev/null +++ b/plugin/android/src/main/cpp/lib/spline/spline.cpp @@ -0,0 +1,763 @@ +#include "./spline.h" +#include +#include +#include +#include +#include +#include +#include + +namespace { + +// band matrix solver +class band_matrix { +private: + std::vector> m_upper; // upper band + std::vector> m_lower; // lower band +public: + band_matrix(){}; // constructor + band_matrix(int dim, int n_u, int n_l); // constructor + ~band_matrix(){}; // destructor + void resize(int dim, int n_u, int n_l); // init with dim,n_u,n_l + int dim() const; // matrix dimension + int num_upper() const { return (int)m_upper.size() - 1; } + int num_lower() const { return (int)m_lower.size() - 1; } + // access operator + double &operator()(int i, int j); // write + double operator()(int i, int j) const; // read + // we can store an additional diagonal (in m_lower) + double &saved_diag(int i); + double saved_diag(int i) const; + void lu_decompose(); + std::vector r_solve(const std::vector &b) const; + std::vector l_solve(const std::vector &b) const; + std::vector lu_solve(const std::vector &b, + bool is_lu_decomposed = false); +}; + +double get_eps(); + +std::vector solve_cubic(double a, double b, double c, double d, + int newton_iter = 0); + +} // namespace + +namespace tk { + +void spline::set_boundary(spline::bd_type left, double left_value, + spline::bd_type right, double right_value) { + assert(m_x.size() == 0); // set_points() must not have happened yet + m_left = left; + m_right = right; + m_left_value = left_value; + m_right_value = right_value; +} + +void spline::set_coeffs_from_b() { + assert(m_x.size() == m_y.size()); + assert(m_x.size() == m_b.size()); + assert(m_x.size() > 2); + size_t n = m_b.size(); + if (m_c.size() != n) + m_c.resize(n); + if (m_d.size() != n) + m_d.resize(n); + + for (size_t i = 0; i < n - 1; i++) { + const double h = m_x[i + 1] - m_x[i]; + // from continuity and differentiability condition + m_c[i] = + (3.0 * (m_y[i + 1] - m_y[i]) / h - (2.0 * m_b[i] + m_b[i + 1])) / h; + // from differentiability condition + m_d[i] = ((m_b[i + 1] - m_b[i]) / (3.0 * h) - 2.0 / 3.0 * m_c[i]) / h; + } + + // for left extrapolation coefficients + m_c0 = (m_left == first_deriv) ? 0.0 : m_c[0]; +} + +void spline::set_points(const std::vector &x, + const std::vector &y, spline_type type) { + assert(x.size() == y.size()); + assert(x.size() >= 3); + // not-a-knot with 3 points has many solutions + if (m_left == not_a_knot || m_right == not_a_knot) + assert(x.size() >= 4); + m_type = type; + m_made_monotonic = false; + m_x = x; + m_y = y; + int n = (int)x.size(); + // check strict monotonicity of input vector x + for (int i = 0; i < n - 1; i++) { + assert(m_x[i] < m_x[i + 1]); + } + + if (type == linear) { + // linear interpolation + m_d.resize(n); + m_c.resize(n); + m_b.resize(n); + for (int i = 0; i < n - 1; i++) { + m_d[i] = 0.0; + m_c[i] = 0.0; + m_b[i] = (m_y[i + 1] - m_y[i]) / (m_x[i + 1] - m_x[i]); + } + // ignore boundary conditions, set slope equal to the last segment + m_b[n - 1] = m_b[n - 2]; + m_c[n - 1] = 0.0; + m_d[n - 1] = 0.0; + } else if (type == cspline) { + // classical cubic splines which are C^2 (twice cont differentiable) + // this requires solving an equation system + + // setting up the matrix and right hand side of the equation system + // for the parameters b[] + int n_upper = (m_left == spline::not_a_knot) ? 2 : 1; + int n_lower = (m_right == spline::not_a_knot) ? 2 : 1; + band_matrix A(n, n_upper, n_lower); + std::vector rhs(n); + for (int i = 1; i < n - 1; i++) { + A(i, i - 1) = 1.0 / 3.0 * (x[i] - x[i - 1]); + A(i, i) = 2.0 / 3.0 * (x[i + 1] - x[i - 1]); + A(i, i + 1) = 1.0 / 3.0 * (x[i + 1] - x[i]); + rhs[i] = (y[i + 1] - y[i]) / (x[i + 1] - x[i]) - + (y[i] - y[i - 1]) / (x[i] - x[i - 1]); + } + // boundary conditions + if (m_left == spline::second_deriv) { + // 2*c[0] = f'' + A(0, 0) = 2.0; + A(0, 1) = 0.0; + rhs[0] = m_left_value; + } else if (m_left == spline::first_deriv) { + // b[0] = f', needs to be re-expressed in terms of c: + // (2c[0]+c[1])(x[1]-x[0]) = 3 ((y[1]-y[0])/(x[1]-x[0]) - f') + A(0, 0) = 2.0 * (x[1] - x[0]); + A(0, 1) = 1.0 * (x[1] - x[0]); + rhs[0] = 3.0 * ((y[1] - y[0]) / (x[1] - x[0]) - m_left_value); + } else if (m_left == spline::not_a_knot) { + // f'''(x[1]) exists, i.e. d[0]=d[1], or re-expressed in c: + // -h1*c[0] + (h0+h1)*c[1] - h0*c[2] = 0 + A(0, 0) = -(x[2] - x[1]); + A(0, 1) = x[2] - x[0]; + A(0, 2) = -(x[1] - x[0]); + rhs[0] = 0.0; + } else { + assert(false); + } + if (m_right == spline::second_deriv) { + // 2*c[n-1] = f'' + A(n - 1, n - 1) = 2.0; + A(n - 1, n - 2) = 0.0; + rhs[n - 1] = m_right_value; + } else if (m_right == spline::first_deriv) { + // b[n-1] = f', needs to be re-expressed in terms of c: + // (c[n-2]+2c[n-1])(x[n-1]-x[n-2]) + // = 3 (f' - (y[n-1]-y[n-2])/(x[n-1]-x[n-2])) + A(n - 1, n - 1) = 2.0 * (x[n - 1] - x[n - 2]); + A(n - 1, n - 2) = 1.0 * (x[n - 1] - x[n - 2]); + rhs[n - 1] = + 3.0 * (m_right_value - (y[n - 1] - y[n - 2]) / (x[n - 1] - x[n - 2])); + } else if (m_right == spline::not_a_knot) { + // f'''(x[n-2]) exists, i.e. d[n-3]=d[n-2], or re-expressed in c: + // -h_{n-2}*c[n-3] + (h_{n-3}+h_{n-2})*c[n-2] - h_{n-3}*c[n-1] = 0 + A(n - 1, n - 3) = -(x[n - 1] - x[n - 2]); + A(n - 1, n - 2) = x[n - 1] - x[n - 3]; + A(n - 1, n - 1) = -(x[n - 2] - x[n - 3]); + rhs[0] = 0.0; + } else { + assert(false); + } + + // solve the equation system to obtain the parameters c[] + m_c = A.lu_solve(rhs); + + // calculate parameters b[] and d[] based on c[] + m_d.resize(n); + m_b.resize(n); + for (int i = 0; i < n - 1; i++) { + m_d[i] = 1.0 / 3.0 * (m_c[i + 1] - m_c[i]) / (x[i + 1] - x[i]); + m_b[i] = (y[i + 1] - y[i]) / (x[i + 1] - x[i]) - + 1.0 / 3.0 * (2.0 * m_c[i] + m_c[i + 1]) * (x[i + 1] - x[i]); + } + // for the right extrapolation coefficients (zero cubic term) + // f_{n-1}(x) = y_{n-1} + b*(x-x_{n-1}) + c*(x-x_{n-1})^2 + double h = x[n - 1] - x[n - 2]; + // m_c[n-1] is determined by the boundary condition + m_d[n - 1] = 0.0; + m_b[n - 1] = 3.0 * m_d[n - 2] * h * h + 2.0 * m_c[n - 2] * h + + m_b[n - 2]; // = f'_{n-2}(x_{n-1}) + if (m_right == first_deriv) + m_c[n - 1] = 0.0; // force linear extrapolation + + } else if (type == cspline_hermite) { + // hermite cubic splines which are C^1 (cont. differentiable) + // and derivatives are specified on each grid point + // (here we use 3-point finite differences) + m_b.resize(n); + m_c.resize(n); + m_d.resize(n); + // set b to match 1st order derivative finite difference + for (int i = 1; i < n - 1; i++) { + const double h = m_x[i + 1] - m_x[i]; + const double hl = m_x[i] - m_x[i - 1]; + m_b[i] = -h / (hl * (hl + h)) * m_y[i - 1] + + (h - hl) / (hl * h) * m_y[i] + hl / (h * (hl + h)) * m_y[i + 1]; + } + // boundary conditions determine b[0] and b[n-1] + if (m_left == first_deriv) { + m_b[0] = m_left_value; + } else if (m_left == second_deriv) { + const double h = m_x[1] - m_x[0]; + m_b[0] = 0.5 * + (-m_b[1] - 0.5 * m_left_value * h + 3.0 * (m_y[1] - m_y[0]) / h); + } else if (m_left == not_a_knot) { + // f''' continuous at x[1] + const double h0 = m_x[1] - m_x[0]; + const double h1 = m_x[2] - m_x[1]; + m_b[0] = -m_b[1] + 2.0 * (m_y[1] - m_y[0]) / h0 + + h0 * h0 / (h1 * h1) * + (m_b[1] + m_b[2] - 2.0 * (m_y[2] - m_y[1]) / h1); + } else { + assert(false); + } + if (m_right == first_deriv) { + m_b[n - 1] = m_right_value; + m_c[n - 1] = 0.0; + } else if (m_right == second_deriv) { + const double h = m_x[n - 1] - m_x[n - 2]; + m_b[n - 1] = 0.5 * (-m_b[n - 2] + 0.5 * m_right_value * h + + 3.0 * (m_y[n - 1] - m_y[n - 2]) / h); + m_c[n - 1] = 0.5 * m_right_value; + } else if (m_right == not_a_knot) { + // f''' continuous at x[n-2] + const double h0 = m_x[n - 2] - m_x[n - 3]; + const double h1 = m_x[n - 1] - m_x[n - 2]; + m_b[n - 1] = + -m_b[n - 2] + 2.0 * (m_y[n - 1] - m_y[n - 2]) / h1 + + h1 * h1 / (h0 * h0) * + (m_b[n - 3] + m_b[n - 2] - 2.0 * (m_y[n - 2] - m_y[n - 3]) / h0); + // f'' continuous at x[n-1]: c[n-1] = 3*d[n-2]*h[n-2] + c[n-1] + m_c[n - 1] = (m_b[n - 2] + 2.0 * m_b[n - 1]) / h1 - + 3.0 * (m_y[n - 1] - m_y[n - 2]) / (h1 * h1); + } else { + assert(false); + } + m_d[n - 1] = 0.0; + + // parameters c and d are determined by continuity and differentiability + set_coeffs_from_b(); + + } else { + assert(false); + } + + // for left extrapolation coefficients + m_c0 = (m_left == first_deriv) ? 0.0 : m_c[0]; +} + +bool spline::make_monotonic() { + assert(m_x.size() == m_y.size()); + assert(m_x.size() == m_b.size()); + assert(m_x.size() > 2); + bool modified = false; + const int n = (int)m_x.size(); + // make sure: input data monotonic increasing --> b_i>=0 + // input data monotonic decreasing --> b_i<=0 + for (int i = 0; i < n; i++) { + int im1 = std::max(i - 1, 0); + int ip1 = std::min(i + 1, n - 1); + if (((m_y[im1] <= m_y[i]) && (m_y[i] <= m_y[ip1]) && m_b[i] < 0.0) || + ((m_y[im1] >= m_y[i]) && (m_y[i] >= m_y[ip1]) && m_b[i] > 0.0)) { + modified = true; + m_b[i] = 0.0; + } + } + // if input data is monotonic (b[i], b[i+1], avg have all the same sign) + // ensure a sufficient criteria for monotonicity is satisfied: + // sqrt(b[i]^2+b[i+1]^2) <= 3 |avg|, with avg=(y[i+1]-y[i])/h, + for (int i = 0; i < n - 1; i++) { + double h = m_x[i + 1] - m_x[i]; + double avg = (m_y[i + 1] - m_y[i]) / h; + if (avg == 0.0 && (m_b[i] != 0.0 || m_b[i + 1] != 0.0)) { + modified = true; + m_b[i] = 0.0; + m_b[i + 1] = 0.0; + } else if ((m_b[i] >= 0.0 && m_b[i + 1] >= 0.0 && avg > 0.0) || + (m_b[i] <= 0.0 && m_b[i + 1] <= 0.0 && avg < 0.0)) { + // input data is monotonic + double r = + sqrt(m_b[i] * m_b[i] + m_b[i + 1] * m_b[i + 1]) / std::fabs(avg); + if (r > 3.0) { + // sufficient criteria for monotonicity: r<=3 + // adjust b[i] and b[i+1] + modified = true; + m_b[i] *= (3.0 / r); + m_b[i + 1] *= (3.0 / r); + } + } + } + + if (modified == true) { + set_coeffs_from_b(); + m_made_monotonic = true; + } + + return modified; +} + +// return the closest idx so that m_x[idx] <= x (return 0 if x::const_iterator it; + it = std::upper_bound(m_x.begin(), m_x.end(), x); // *it > x + size_t idx = std::max(int(it - m_x.begin()) - 1, 0); // m_x[idx] <= x + return idx; +} + +double spline::operator()(double x) const { + // polynomial evaluation using Horner's scheme + // TODO: consider more numerically accurate algorithms, e.g.: + // - Clenshaw + // - Even-Odd method by A.C.R. Newbery + // - Compensated Horner Scheme + size_t n = m_x.size(); + size_t idx = find_closest(x); + + double h = x - m_x[idx]; + double interpol; + if (x < m_x[0]) { + // extrapolation to the left + interpol = (m_c0 * h + m_b[0]) * h + m_y[0]; + } else if (x > m_x[n - 1]) { + // extrapolation to the right + interpol = (m_c[n - 1] * h + m_b[n - 1]) * h + m_y[n - 1]; + } else { + // interpolation + interpol = ((m_d[idx] * h + m_c[idx]) * h + m_b[idx]) * h + m_y[idx]; + } + return interpol; +} + +double spline::deriv(int order, double x) const { + assert(order > 0); + size_t n = m_x.size(); + size_t idx = find_closest(x); + + double h = x - m_x[idx]; + double interpol; + if (x < m_x[0]) { + // extrapolation to the left + switch (order) { + case 1: + interpol = 2.0 * m_c0 * h + m_b[0]; + break; + case 2: + interpol = 2.0 * m_c0; + break; + default: + interpol = 0.0; + break; + } + } else if (x > m_x[n - 1]) { + // extrapolation to the right + switch (order) { + case 1: + interpol = 2.0 * m_c[n - 1] * h + m_b[n - 1]; + break; + case 2: + interpol = 2.0 * m_c[n - 1]; + break; + default: + interpol = 0.0; + break; + } + } else { + // interpolation + switch (order) { + case 1: + interpol = (3.0 * m_d[idx] * h + 2.0 * m_c[idx]) * h + m_b[idx]; + break; + case 2: + interpol = 6.0 * m_d[idx] * h + 2.0 * m_c[idx]; + break; + case 3: + interpol = 6.0 * m_d[idx]; + break; + default: + interpol = 0.0; + break; + } + } + return interpol; +} + +std::vector spline::solve(double y, bool ignore_extrapolation) const { + std::vector x; // roots for the entire spline + std::vector root; // roots for each piecewise cubic + const size_t n = m_x.size(); + + // left extrapolation + if (ignore_extrapolation == false) { + root = solve_cubic(m_y[0] - y, m_b[0], m_c0, 0.0, 1); + for (size_t j = 0; j < root.size(); j++) { + if (root[j] < 0.0) { + x.push_back(m_x[0] + root[j]); + } + } + } + + // brute force check if piecewise cubic has roots in their resp. segment + // TODO: make more efficient + for (size_t i = 0; i < n - 1; i++) { + root = solve_cubic(m_y[i] - y, m_b[i], m_c[i], m_d[i], 1); + for (size_t j = 0; j < root.size(); j++) { + double h = (i > 0) ? (m_x[i] - m_x[i - 1]) : 0.0; + double eps = get_eps() * 512.0 * std::min(h, 1.0); + if ((-eps <= root[j]) && (root[j] < m_x[i + 1] - m_x[i])) { + double new_root = m_x[i] + root[j]; + if (x.size() > 0 && x.back() + eps > new_root) { + x.back() = new_root; // avoid spurious duplicate roots + } else { + x.push_back(new_root); + } + } + } + } + + // right extrapolation + if (ignore_extrapolation == false) { + root = solve_cubic(m_y[n - 1] - y, m_b[n - 1], m_c[n - 1], 0.0, 1); + for (size_t j = 0; j < root.size(); j++) { + if (0.0 <= root[j]) { + x.push_back(m_x[n - 1] + root[j]); + } + } + } + + return x; +}; + +std::string spline::info() const { + std::stringstream ss; + ss << "type " << m_type << ", left boundary deriv " << m_left << " = "; + ss << m_left_value << ", right boundary deriv " << m_right << " = "; + ss << m_right_value << std::endl; + if (m_made_monotonic) { + ss << "(spline has been adjusted for piece-wise monotonicity)"; + } + return ss.str(); +} +} // namespace tk + +namespace { + +// band_matrix implementation +// ------------------------- + +band_matrix::band_matrix(int dim, int n_u, int n_l) { resize(dim, n_u, n_l); } +void band_matrix::resize(int dim, int n_u, int n_l) { + assert(dim > 0); + assert(n_u >= 0); + assert(n_l >= 0); + m_upper.resize(n_u + 1); + m_lower.resize(n_l + 1); + for (size_t i = 0; i < m_upper.size(); i++) { + m_upper[i].resize(dim); + } + for (size_t i = 0; i < m_lower.size(); i++) { + m_lower[i].resize(dim); + } +} +int band_matrix::dim() const { + if (m_upper.size() > 0) { + return m_upper[0].size(); + } else { + return 0; + } +} + +// defines the new operator (), so that we can access the elements +// by A(i,j), index going from i=0,...,dim()-1 +double &band_matrix::operator()(int i, int j) { + int k = j - i; // what band is the entry + assert((i >= 0) && (i < dim()) && (j >= 0) && (j < dim())); + assert((-num_lower() <= k) && (k <= num_upper())); + // k=0 -> diagonal, k<0 lower left part, k>0 upper right part + if (k >= 0) + return m_upper[k][i]; + else + return m_lower[-k][i]; +} +double band_matrix::operator()(int i, int j) const { + int k = j - i; // what band is the entry + assert((i >= 0) && (i < dim()) && (j >= 0) && (j < dim())); + assert((-num_lower() <= k) && (k <= num_upper())); + // k=0 -> diagonal, k<0 lower left part, k>0 upper right part + if (k >= 0) + return m_upper[k][i]; + else + return m_lower[-k][i]; +} +// second diag (used in LU decomposition), saved in m_lower +double band_matrix::saved_diag(int i) const { + assert((i >= 0) && (i < dim())); + return m_lower[0][i]; +} +double &band_matrix::saved_diag(int i) { + assert((i >= 0) && (i < dim())); + return m_lower[0][i]; +} + +// LR-Decomposition of a band matrix +void band_matrix::lu_decompose() { + int i_max, j_max; + int j_min; + double x; + + // preconditioning + // normalize column i so that a_ii=1 + for (int i = 0; i < this->dim(); i++) { + assert(this->operator()(i, i) != 0.0); + this->saved_diag(i) = 1.0 / this->operator()(i, i); + j_min = std::max(0, i - this->num_lower()); + j_max = std::min(this->dim() - 1, i + this->num_upper()); + for (int j = j_min; j <= j_max; j++) { + this->operator()(i, j) *= this->saved_diag(i); + } + this->operator()(i, i) = 1.0; // prevents rounding errors + } + + // Gauss LR-Decomposition + for (int k = 0; k < this->dim(); k++) { + i_max = std::min(this->dim() - 1, + k + this->num_lower()); // num_lower not a mistake! + for (int i = k + 1; i <= i_max; i++) { + assert(this->operator()(k, k) != 0.0); + x = -this->operator()(i, k) / this->operator()(k, k); + this->operator()(i, k) = -x; // assembly part of L + j_max = std::min(this->dim() - 1, k + this->num_upper()); + for (int j = k + 1; j <= j_max; j++) { + // assembly part of R + this->operator()(i, j) = + this->operator()(i, j) + x * this->operator()(k, j); + } + } + } +} +// solves Ly=b +std::vector band_matrix::l_solve(const std::vector &b) const { + assert(this->dim() == (int)b.size()); + std::vector x(this->dim()); + int j_start; + double sum; + for (int i = 0; i < this->dim(); i++) { + sum = 0; + j_start = std::max(0, i - this->num_lower()); + for (int j = j_start; j < i; j++) + sum += this->operator()(i, j) * x[j]; + x[i] = (b[i] * this->saved_diag(i)) - sum; + } + return x; +} +// solves Rx=y +std::vector band_matrix::r_solve(const std::vector &b) const { + assert(this->dim() == (int)b.size()); + std::vector x(this->dim()); + int j_stop; + double sum; + for (int i = this->dim() - 1; i >= 0; i--) { + sum = 0; + j_stop = std::min(this->dim() - 1, i + this->num_upper()); + for (int j = i + 1; j <= j_stop; j++) + sum += this->operator()(i, j) * x[j]; + x[i] = (b[i] - sum) / this->operator()(i, i); + } + return x; +} + +std::vector band_matrix::lu_solve(const std::vector &b, + bool is_lu_decomposed) { + assert(this->dim() == (int)b.size()); + std::vector x, y; + if (is_lu_decomposed == false) { + this->lu_decompose(); + } + y = this->l_solve(b); + x = this->r_solve(y); + return x; +} + +// machine precision of a double, i.e. the successor of 1 is 1+eps +double get_eps() { + // return std::numeric_limits::epsilon(); // __DBL_EPSILON__ + return 2.2204460492503131e-16; // 2^-52 +} + +// solutions for a + b*x = 0 +std::vector solve_linear(double a, double b) { + std::vector x; // roots + if (b == 0.0) { + if (a == 0.0) { + // 0*x = 0 + x.resize(1); + x[0] = 0.0; // any x solves it but we need to pick one + return x; + } else { + // 0*x + ... = 0, no solution + return x; + } + } else { + x.resize(1); + x[0] = -a / b; + return x; + } +} + +// solutions for a + b*x + c*x^2 = 0 +std::vector solve_quadratic(double a, double b, double c, + int newton_iter = 0) { + if (c == 0.0) { + return solve_linear(a, b); + } + // rescale so that we solve x^2 + 2p x + q = (x+p)^2 + q - p^2 = 0 + double p = 0.5 * b / c; + double q = a / c; + double discr = p * p - q; + const double eps = 0.5 * get_eps(); + double discr_err = (6.0 * (p * p) + 3.0 * fabs(q) + fabs(discr)) * eps; + + std::vector x; // roots + if (fabs(discr) <= discr_err) { + // discriminant is zero --> one root + x.resize(1); + x[0] = -p; + } else if (discr < 0) { + // no root + } else { + // two roots + x.resize(2); + x[0] = -p - sqrt(discr); + x[1] = -p + sqrt(discr); + } + + // improve solution via newton steps + for (size_t i = 0; i < x.size(); i++) { + for (int k = 0; k < newton_iter; k++) { + double f = (c * x[i] + b) * x[i] + a; + double f1 = 2.0 * c * x[i] + b; + // only adjust if slope is large enough + if (fabs(f1) > 1e-8) { + x[i] -= f / f1; + } + } + } + + return x; +} + +// solutions for the cubic equation: a + b*x +c*x^2 + d*x^3 = 0 +// this is a naive implementation of the analytic solution without +// optimisation for speed or numerical accuracy +// newton_iter: number of newton iterations to improve analytical solution +// see also +// gsl: gsl_poly_solve_cubic() in solve_cubic.c +// octave: roots.m - via eigenvalues of the Frobenius companion matrix +std::vector solve_cubic(double a, double b, double c, double d, + int newton_iter) { + if (d == 0.0) { + return solve_quadratic(a, b, c, newton_iter); + } + + // convert to normalised form: a + bx + cx^2 + x^3 = 0 + if (d != 1.0) { + a /= d; + b /= d; + c /= d; + } + + // convert to depressed cubic: z^3 - 3pz - 2q = 0 + // via substitution: z = x + c/3 + std::vector z; // roots of the depressed cubic + double p = -(1.0 / 3.0) * b + (1.0 / 9.0) * (c * c); + double r = 2.0 * (c * c) - 9.0 * b; + double q = -0.5 * a - (1.0 / 54.0) * (c * r); + double discr = p * p * p - q * q; // discriminant + // calculating numerical round-off errors with assumptions: + // - each operation is precise but each intermediate result x + // when stored has max error of x*eps + // - only multiplication with a power of 2 introduces no new error + // - a,b,c,d and some fractions (e.g. 1/3) have rounding errors eps + // - p_err << |p|, q_err << |q|, ... (this is violated in rare cases) + // would be more elegant to use boost::numeric::interval + const double eps = get_eps(); + double p_err = + eps * ((3.0 / 3.0) * fabs(b) + (4.0 / 9.0) * (c * c) + fabs(p)); + double r_err = eps * (6.0 * (c * c) + 18.0 * fabs(b) + fabs(r)); + double q_err = 0.5 * fabs(a) * eps + + (1.0 / 54.0) * fabs(c) * (r_err + fabs(r) * 3.0 * eps) + + fabs(q) * eps; + double discr_err = (p * p) * (3.0 * p_err + fabs(p) * 2.0 * eps) + + fabs(q) * (2.0 * q_err + fabs(q) * eps) + + fabs(discr) * eps; + + // depending on the discriminant we get different solutions + if (fabs(discr) <= discr_err) { + // discriminant zero: one or two real roots + if (fabs(p) <= p_err) { + // p and q are zero: single root + z.resize(1); + z[0] = 0.0; // triple root + } else { + z.resize(2); + z[0] = 2.0 * q / p; // single root + z[1] = -0.5 * z[0]; // double root + } + } else if (discr > 0) { + // three real roots: via trigonometric solution + z.resize(3); + double ac = (1.0 / 3.0) * acos(q / (p * sqrt(p))); + double sq = 2.0 * sqrt(p); + z[0] = sq * cos(ac); + z[1] = sq * cos(ac - 2.0 * M_PI / 3.0); + z[2] = sq * cos(ac - 4.0 * M_PI / 3.0); + } else if (discr < 0.0) { + // single real root: via Cardano's fromula + z.resize(1); + double sgnq = (q >= 0 ? 1 : -1); + double basis = fabs(q) + sqrt(-discr); + double C = sgnq * pow(basis, 1.0 / 3.0); // c++11 has std::cbrt() + z[0] = C + p / C; + } + for (size_t i = 0; i < z.size(); i++) { + // convert depressed cubic roots to original cubic: x = z - c/3 + z[i] -= (1.0 / 3.0) * c; + // improve solution via newton steps + for (int k = 0; k < newton_iter; k++) { + double f = ((z[i] + c) * z[i] + b) * z[i] + a; + double f1 = (3.0 * z[i] + 2.0 * c) * z[i] + b; + // only adjust if slope is large enough + if (fabs(f1) > 1e-8) { + z[i] -= f / f1; + } + } + } + // ensure if a=0 we get exactly x=0 as root + // TODO: remove this fudge + if (a == 0.0) { + assert(z.size() > 0); // cubic should always have at least one root + double xmin = fabs(z[0]); + size_t imin = 0; + for (size_t i = 1; i < z.size(); i++) { + if (xmin > fabs(z[i])) { + xmin = fabs(z[i]); + imin = i; + } + } + z[imin] = 0.0; // replace the smallest absolute value with 0 + } + std::sort(z.begin(), z.end()); + return z; +} + +} // namespace diff --git a/plugin/android/src/main/cpp/lib/spline/spline.h b/plugin/android/src/main/cpp/lib/spline/spline.h new file mode 100644 index 00000000..9e34fd83 --- /dev/null +++ b/plugin/android/src/main/cpp/lib/spline/spline.h @@ -0,0 +1,123 @@ +/* + * spline.h + * + * simple cubic spline interpolation library without external + * dependencies + * + * --------------------------------------------------------------------- + * Copyright (C) 2011, 2014, 2016, 2021 Tino Kluge (ttk448 at gmail.com) + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU General Public License + * as published by the Free Software Foundation; either version 2 + * of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * --------------------------------------------------------------------- + * + */ + +#pragma once + +#include +#include +#include + +namespace tk { + +// spline interpolation +class spline { +public: + // spline types + enum spline_type { + linear = 10, // linear interpolation + cspline = 30, // cubic splines (classical C^2) + cspline_hermite = 31 // cubic hermite splines (local, only C^1) + }; + + // boundary condition type for the spline end-points + enum bd_type { first_deriv = 1, second_deriv = 2, not_a_knot = 3 }; + +protected: + std::vector m_x, m_y; // x,y coordinates of points + // interpolation parameters + // f(x) = a_i + b_i*(x-x_i) + c_i*(x-x_i)^2 + d_i*(x-x_i)^3 + // where a_i = y_i, or else it won't go through grid points + std::vector m_b, m_c, m_d; // spline coefficients + double m_c0; // for left extrapolation + spline_type m_type; + bd_type m_left, m_right; + double m_left_value, m_right_value; + bool m_made_monotonic; + void set_coeffs_from_b(); // calculate c_i, d_i from b_i + size_t find_closest(double x) const; // closest idx so that m_x[idx]<=x + +public: + // default constructor: set boundary condition to be zero curvature + // at both ends, i.e. natural splines + spline() + : m_type(cspline), m_left(second_deriv), m_right(second_deriv), + m_left_value(0.0), m_right_value(0.0), m_made_monotonic(false) { + ; + } + spline(const std::vector &X, const std::vector &Y, + spline_type type = cspline, bool make_monotonic = false, + bd_type left = second_deriv, double left_value = 0.0, + bd_type right = second_deriv, double right_value = 0.0) + : m_type(type), m_left(left), m_right(right), m_left_value(left_value), + m_right_value(right_value), + m_made_monotonic(false) // false correct here: make_monotonic() sets it + { + this->set_points(X, Y, m_type); + if (make_monotonic) { + this->make_monotonic(); + } + } + + // modify boundary conditions: if called it must be before set_points() + void set_boundary(bd_type left, double left_value, bd_type right, + double right_value); + + // set all data points (cubic_spline=false means linear interpolation) + void set_points(const std::vector &x, const std::vector &y, + spline_type type = cspline); + + // adjust coefficients so that the spline becomes piecewise monotonic + // where possible + // this is done by adjusting slopes at grid points by a non-negative + // factor and this will break C^2 + // this can also break boundary conditions if adjustments need to + // be made at the boundary points + // returns false if no adjustments have been made, true otherwise + bool make_monotonic(); + + // evaluates the spline at point x + double operator()(double x) const; + double deriv(int order, double x) const; + + // solves for all x so that: spline(x) = y + std::vector solve(double y, bool ignore_extrapolation = true) const; + + // returns the input data points + std::vector get_x() const { return m_x; } + std::vector get_y() const { return m_y; } + double get_x_min() const { + assert(!m_x.empty()); + return m_x.front(); + } + double get_x_max() const { + assert(!m_x.empty()); + return m_x.back(); + } + + // spline info string, i.e. spline type, boundary conditions etc. + std::string info() const; +}; + +} // namespace tkPLINE_H */ diff --git a/plugin/android/src/main/kotlin/com/nkming/nc_photos/plugin/ImageProcessorChannelHandler.kt b/plugin/android/src/main/kotlin/com/nkming/nc_photos/plugin/ImageProcessorChannelHandler.kt index 063bd891..9c6dab53 100644 --- a/plugin/android/src/main/kotlin/com/nkming/nc_photos/plugin/ImageProcessorChannelHandler.kt +++ b/plugin/android/src/main/kotlin/com/nkming/nc_photos/plugin/ImageProcessorChannelHandler.kt @@ -4,9 +4,11 @@ import android.content.Context import android.content.Intent import android.net.Uri import androidx.core.content.ContextCompat +import com.nkming.nc_photos.plugin.image_processor.* import io.flutter.plugin.common.EventChannel import io.flutter.plugin.common.MethodCall import io.flutter.plugin.common.MethodChannel +import java.io.Serializable class ImageProcessorChannelHandler(context: Context) : MethodChannel.MethodCallHandler, EventChannel.StreamHandler { @@ -86,6 +88,36 @@ class ImageProcessorChannelHandler(context: Context) : } } + "colorFilter" -> { + try { + colorFilter( + call.argument("fileUrl")!!, + call.argument("headers"), + call.argument("filename")!!, + call.argument("maxWidth")!!, + call.argument("maxHeight")!!, + call.argument("filters")!!, + result + ) + } catch (e: Throwable) { + logE(TAG, "Uncaught exception", e) + result.error("systemException", e.toString(), null) + } + } + + "filterPreview" -> { + try { + filterPreview( + call.argument("rgba8")!!, + call.argument("filters")!!, + result + ) + } catch (e: Throwable) { + logE(TAG, "Uncaught exception", e) + result.error("systemException", e.toString(), null) + } + } + else -> result.notImplemented() } } @@ -142,6 +174,34 @@ class ImageProcessorChannelHandler(context: Context) : } ) + private fun colorFilter( + fileUrl: String, headers: Map?, filename: String, + maxWidth: Int, maxHeight: Int, filters: List>, + result: MethodChannel.Result + ) { + // convert to serializable + val l = arrayListOf() + filters.mapTo(l, { HashMap(it) }) + method( + fileUrl, headers, filename, maxWidth, maxHeight, + ImageProcessorService.METHOD_COLOR_FILTER, result, + onIntent = { + it.putExtra(ImageProcessorService.EXTRA_FILTERS, l) + } + ) + } + + private fun filterPreview( + rgba8: Map, filters: List>, + result: MethodChannel.Result + ) { + var img = Rgba8Image.fromJson(rgba8) + for (f in filters.map(ColorFilter::fromJson)) { + img = f.apply(img) + } + result.success(img.toJson()) + } + private fun method( fileUrl: String, headers: Map?, filename: String, maxWidth: Int, maxHeight: Int, method: String, @@ -165,3 +225,24 @@ class ImageProcessorChannelHandler(context: Context) : private val context = context private var eventSink: EventChannel.EventSink? = null } + +interface ColorFilter { + companion object { + fun fromJson(json: Map): ColorFilter { + return when (json["type"]) { + "brightness" -> Brightness((json["weight"] as Double).toFloat()) + "contrast" -> Contrast((json["weight"] as Double).toFloat()) + "whitePoint" -> WhitePoint((json["weight"] as Double).toFloat()) + "blackPoint" -> BlackPoint((json["weight"] as Double).toFloat()) + "saturation" -> Saturation((json["weight"] as Double).toFloat()) + "warmth" -> Warmth((json["weight"] as Double).toFloat()) + "tint" -> Tint((json["weight"] as Double).toFloat()) + else -> throw IllegalArgumentException( + "Unknown type: ${json["type"]}" + ) + } + } + } + + fun apply(rgba8: Rgba8Image): Rgba8Image +} diff --git a/plugin/android/src/main/kotlin/com/nkming/nc_photos/plugin/ImageProcessorService.kt b/plugin/android/src/main/kotlin/com/nkming/nc_photos/plugin/ImageProcessorService.kt index 09b3eddd..09abf16c 100644 --- a/plugin/android/src/main/kotlin/com/nkming/nc_photos/plugin/ImageProcessorService.kt +++ b/plugin/android/src/main/kotlin/com/nkming/nc_photos/plugin/ImageProcessorService.kt @@ -17,11 +17,9 @@ import androidx.core.app.NotificationChannelCompat import androidx.core.app.NotificationCompat import androidx.core.app.NotificationManagerCompat import androidx.exifinterface.media.ExifInterface -import com.nkming.nc_photos.plugin.image_processor.ArbitraryStyleTransfer -import com.nkming.nc_photos.plugin.image_processor.DeepLab3Portrait -import com.nkming.nc_photos.plugin.image_processor.Esrgan -import com.nkming.nc_photos.plugin.image_processor.ZeroDce +import com.nkming.nc_photos.plugin.image_processor.* import java.io.File +import java.io.Serializable import java.net.HttpURLConnection import java.net.URL @@ -32,6 +30,7 @@ class ImageProcessorService : Service() { const val METHOD_DEEP_LAP_PORTRAIT = "DeepLab3Portrait" const val METHOD_ESRGAN = "Esrgan" const val METHOD_ARBITRARY_STYLE_TRANSFER = "ArbitraryStyleTransfer" + const val METHOD_COLOR_FILTER = "ColorFilter" const val EXTRA_FILE_URL = "fileUrl" const val EXTRA_HEADERS = "headers" const val EXTRA_FILENAME = "filename" @@ -41,6 +40,17 @@ class ImageProcessorService : Service() { const val EXTRA_ITERATION = "iteration" const val EXTRA_STYLE_URI = "styleUri" const val EXTRA_WEIGHT = "weight" + const val EXTRA_FILTERS = "filters" + + val ENHANCE_METHODS = listOf( + METHOD_ZERO_DCE, + METHOD_DEEP_LAP_PORTRAIT, + METHOD_ESRGAN, + METHOD_ARBITRARY_STYLE_TRANSFER, + ) + val EDIT_METHODS = listOf( + METHOD_COLOR_FILTER, + ) private const val ACTION_CANCEL = "cancel" @@ -116,6 +126,7 @@ class ImageProcessorService : Service() { METHOD_ARBITRARY_STYLE_TRANSFER -> onArbitraryStyleTransfer( startId, intent.extras!! ) + METHOD_COLOR_FILTER -> onColorFilter(startId, intent.extras!!) else -> { logE(TAG, "Unknown method: $method") // we can't call stopSelf here as it'll stop the service even if @@ -159,6 +170,18 @@ class ImageProcessorService : Service() { ) } + private fun onColorFilter(startId: Int, extras: Bundle) { + val filters = extras.getSerializable(EXTRA_FILTERS)!! + .asType>() + .map { it.asType>() } + return onMethod( + startId, extras, METHOD_COLOR_FILTER, + args = mapOf( + "filters" to filters, + ) + ) + } + /** * Handle methods without arguments * @@ -231,7 +254,7 @@ class ImageProcessorService : Service() { ) return NotificationCompat.Builder(this, CHANNEL_ID).run { setSmallIcon(R.drawable.outline_image_white_24) - setContentTitle("Successfully enhanced image") + setContentTitle("Successfully processed image") setContentText("Tap to view the result") setContentIntent(pi) setAutoCancel(true) @@ -244,7 +267,7 @@ class ImageProcessorService : Service() { ): Notification { return NotificationCompat.Builder(this, CHANNEL_ID).run { setSmallIcon(R.drawable.outline_error_outline_white_24) - setContentTitle("Failed enhancing image") + setContentTitle("Failed processing image") setContentText(exception.message) build() } @@ -260,7 +283,7 @@ class ImageProcessorService : Service() { ) return NotificationCompat.Builder(this, CHANNEL_ID).run { setSmallIcon(R.drawable.outline_auto_fix_high_white_24) - setContentTitle("Preparing to restart photo enhancement") + setContentTitle("Preparing to restart photo processing") addAction( 0, getString(android.R.string.cancel), cancelPendingIntent ) @@ -552,13 +575,28 @@ private open class ImageProcessorCommandTask(context: Context) : cmd.args["weight"] as Float ).infer(fileUri) + ImageProcessorService.METHOD_COLOR_FILTER -> { + @Suppress("Unchecked_cast") + ColorFilterProcessor( + context, cmd.maxWidth, cmd.maxHeight, + cmd.args["filters"] as List>, + ).apply(fileUri) + } + else -> throw IllegalArgumentException( "Unknown method: ${cmd.method}" ) } }) handleCancel() - saveBitmap(output, cmd.filename, file) + saveBitmap( + output, cmd.filename, file, + if (cmd.method in ImageProcessorService.EDIT_METHODS) { + "Edited Photos" + } else { + "Enhanced Photos" + } + ) } finally { file.delete() } @@ -598,7 +636,7 @@ private open class ImageProcessorCommandTask(context: Context) : } private fun saveBitmap( - bitmap: Bitmap, filename: String, srcFile: File + bitmap: Bitmap, filename: String, srcFile: File, subDir: String ): Uri { logI(TAG, "[saveBitmap] $filename") val outFile = File.createTempFile("out", null, getTempDir(context)) @@ -619,7 +657,7 @@ private open class ImageProcessorCommandTask(context: Context) : // move file to user accessible storage val uri = MediaStoreUtil.copyFileToDownload( context, Uri.fromFile(outFile), filename, - "Photos (for Nextcloud)/Enhanced Photos" + "Photos (for Nextcloud)/$subDir" ) outFile.delete() return uri diff --git a/plugin/android/src/main/kotlin/com/nkming/nc_photos/plugin/Util.kt b/plugin/android/src/main/kotlin/com/nkming/nc_photos/plugin/Util.kt index 3e50c336..2cf9fe0c 100644 --- a/plugin/android/src/main/kotlin/com/nkming/nc_photos/plugin/Util.kt +++ b/plugin/android/src/main/kotlin/com/nkming/nc_photos/plugin/Util.kt @@ -3,6 +3,7 @@ package com.nkming.nc_photos.plugin import android.app.PendingIntent import android.os.Build import android.os.Bundle +import java.io.Serializable import java.net.HttpURLConnection fun getPendingIntentFlagImmutable(): Int { @@ -40,3 +41,6 @@ inline fun measureTime(tag: String, message: String, block: () -> T): T { } fun Bundle.getIntOrNull(key: String) = get(key) as? Int + +@Suppress("Unchecked_cast") +fun Serializable.asType() = this as T diff --git a/plugin/android/src/main/kotlin/com/nkming/nc_photos/plugin/image_processor/BlackPoint.kt b/plugin/android/src/main/kotlin/com/nkming/nc_photos/plugin/image_processor/BlackPoint.kt new file mode 100644 index 00000000..2b4f47f0 --- /dev/null +++ b/plugin/android/src/main/kotlin/com/nkming/nc_photos/plugin/image_processor/BlackPoint.kt @@ -0,0 +1,14 @@ +package com.nkming.nc_photos.plugin.image_processor + +import com.nkming.nc_photos.plugin.ColorFilter + +class BlackPoint(val weight: Float) : ColorFilter { + override fun apply(rgba8: Rgba8Image) = Rgba8Image( + applyNative(rgba8.pixel, rgba8.width, rgba8.height, weight), + rgba8.width, rgba8.height + ) + + private external fun applyNative( + rgba8: ByteArray, width: Int, height: Int, weight: Float + ): ByteArray +} diff --git a/plugin/android/src/main/kotlin/com/nkming/nc_photos/plugin/image_processor/Brightness.kt b/plugin/android/src/main/kotlin/com/nkming/nc_photos/plugin/image_processor/Brightness.kt new file mode 100644 index 00000000..04174c2e --- /dev/null +++ b/plugin/android/src/main/kotlin/com/nkming/nc_photos/plugin/image_processor/Brightness.kt @@ -0,0 +1,14 @@ +package com.nkming.nc_photos.plugin.image_processor + +import com.nkming.nc_photos.plugin.ColorFilter + +class Brightness(val weight: Float) : ColorFilter { + override fun apply(rgba8: Rgba8Image) = Rgba8Image( + applyNative(rgba8.pixel, rgba8.width, rgba8.height, weight), + rgba8.width, rgba8.height + ) + + private external fun applyNative( + rgba8: ByteArray, width: Int, height: Int, weight: Float + ): ByteArray +} diff --git a/plugin/android/src/main/kotlin/com/nkming/nc_photos/plugin/image_processor/ColorFilterProcessor.kt b/plugin/android/src/main/kotlin/com/nkming/nc_photos/plugin/image_processor/ColorFilterProcessor.kt new file mode 100644 index 00000000..2a50f770 --- /dev/null +++ b/plugin/android/src/main/kotlin/com/nkming/nc_photos/plugin/image_processor/ColorFilterProcessor.kt @@ -0,0 +1,37 @@ +package com.nkming.nc_photos.plugin.image_processor + +import android.content.Context +import android.graphics.Bitmap +import android.net.Uri +import com.nkming.nc_photos.plugin.BitmapResizeMethod +import com.nkming.nc_photos.plugin.BitmapUtil +import com.nkming.nc_photos.plugin.ColorFilter +import com.nkming.nc_photos.plugin.use + +class ColorFilterProcessor( + context: Context, maxWidth: Int, maxHeight: Int, + filters: List> +) { + companion object { + const val TAG = "ColorFilterProcessor" + } + + fun apply(imageUri: Uri): Bitmap { + var img = BitmapUtil.loadImage( + context, imageUri, maxWidth, maxHeight, BitmapResizeMethod.FIT, + isAllowSwapSide = true, shouldUpscale = false + ).use { + Rgba8Image(TfLiteHelper.bitmapToRgba8Array(it), it.width, it.height) + } + + for (f in filters.map(ColorFilter::fromJson)) { + img = f.apply(img) + } + return img.toBitmap() + } + + private val context = context + private val maxWidth = maxWidth + private val maxHeight = maxHeight + private val filters = filters +} diff --git a/plugin/android/src/main/kotlin/com/nkming/nc_photos/plugin/image_processor/Contrast.kt b/plugin/android/src/main/kotlin/com/nkming/nc_photos/plugin/image_processor/Contrast.kt new file mode 100644 index 00000000..766fac6e --- /dev/null +++ b/plugin/android/src/main/kotlin/com/nkming/nc_photos/plugin/image_processor/Contrast.kt @@ -0,0 +1,14 @@ +package com.nkming.nc_photos.plugin.image_processor + +import com.nkming.nc_photos.plugin.ColorFilter + +class Contrast(val weight: Float) : ColorFilter { + override fun apply(rgba8: Rgba8Image) = Rgba8Image( + applyNative(rgba8.pixel, rgba8.width, rgba8.height, weight), + rgba8.width, rgba8.height + ) + + private external fun applyNative( + rgba8: ByteArray, width: Int, height: Int, weight: Float + ): ByteArray +} diff --git a/plugin/android/src/main/kotlin/com/nkming/nc_photos/plugin/image_processor/Cool.kt b/plugin/android/src/main/kotlin/com/nkming/nc_photos/plugin/image_processor/Cool.kt new file mode 100644 index 00000000..f844062f --- /dev/null +++ b/plugin/android/src/main/kotlin/com/nkming/nc_photos/plugin/image_processor/Cool.kt @@ -0,0 +1,14 @@ +package com.nkming.nc_photos.plugin.image_processor + +import com.nkming.nc_photos.plugin.ColorFilter + +class Cool(val weight: Float) : ColorFilter { + override fun apply(rgba8: Rgba8Image) = Rgba8Image( + applyNative(rgba8.pixel, rgba8.width, rgba8.height, weight), + rgba8.width, rgba8.height + ) + + private external fun applyNative( + rgba8: ByteArray, width: Int, height: Int, weight: Float + ): ByteArray +} diff --git a/plugin/android/src/main/kotlin/com/nkming/nc_photos/plugin/image_processor/Saturation.kt b/plugin/android/src/main/kotlin/com/nkming/nc_photos/plugin/image_processor/Saturation.kt new file mode 100644 index 00000000..0569055f --- /dev/null +++ b/plugin/android/src/main/kotlin/com/nkming/nc_photos/plugin/image_processor/Saturation.kt @@ -0,0 +1,14 @@ +package com.nkming.nc_photos.plugin.image_processor + +import com.nkming.nc_photos.plugin.ColorFilter + +class Saturation(val weight: Float) : ColorFilter { + override fun apply(rgba8: Rgba8Image) = Rgba8Image( + applyNative(rgba8.pixel, rgba8.width, rgba8.height, weight), + rgba8.width, rgba8.height + ) + + private external fun applyNative( + rgba8: ByteArray, width: Int, height: Int, weight: Float + ): ByteArray +} diff --git a/plugin/android/src/main/kotlin/com/nkming/nc_photos/plugin/image_processor/Tint.kt b/plugin/android/src/main/kotlin/com/nkming/nc_photos/plugin/image_processor/Tint.kt new file mode 100644 index 00000000..41d7c26a --- /dev/null +++ b/plugin/android/src/main/kotlin/com/nkming/nc_photos/plugin/image_processor/Tint.kt @@ -0,0 +1,14 @@ +package com.nkming.nc_photos.plugin.image_processor + +import com.nkming.nc_photos.plugin.ColorFilter + +class Tint(val weight: Float) : ColorFilter { + override fun apply(rgba8: Rgba8Image) = Rgba8Image( + applyNative(rgba8.pixel, rgba8.width, rgba8.height, weight), + rgba8.width, rgba8.height + ) + + private external fun applyNative( + rgba8: ByteArray, width: Int, height: Int, weight: Float + ): ByteArray +} diff --git a/plugin/android/src/main/kotlin/com/nkming/nc_photos/plugin/image_processor/Warmth.kt b/plugin/android/src/main/kotlin/com/nkming/nc_photos/plugin/image_processor/Warmth.kt new file mode 100644 index 00000000..a9593c07 --- /dev/null +++ b/plugin/android/src/main/kotlin/com/nkming/nc_photos/plugin/image_processor/Warmth.kt @@ -0,0 +1,14 @@ +package com.nkming.nc_photos.plugin.image_processor + +import com.nkming.nc_photos.plugin.ColorFilter + +class Warmth(val weight: Float) : ColorFilter { + override fun apply(rgba8: Rgba8Image) = Rgba8Image( + applyNative(rgba8.pixel, rgba8.width, rgba8.height, weight), + rgba8.width, rgba8.height + ) + + private external fun applyNative( + rgba8: ByteArray, width: Int, height: Int, weight: Float + ): ByteArray +} diff --git a/plugin/android/src/main/kotlin/com/nkming/nc_photos/plugin/image_processor/WhitePoint.kt b/plugin/android/src/main/kotlin/com/nkming/nc_photos/plugin/image_processor/WhitePoint.kt new file mode 100644 index 00000000..23751936 --- /dev/null +++ b/plugin/android/src/main/kotlin/com/nkming/nc_photos/plugin/image_processor/WhitePoint.kt @@ -0,0 +1,14 @@ +package com.nkming.nc_photos.plugin.image_processor + +import com.nkming.nc_photos.plugin.ColorFilter + +class WhitePoint(val weight: Float) : ColorFilter { + override fun apply(rgba8: Rgba8Image) = Rgba8Image( + applyNative(rgba8.pixel, rgba8.width, rgba8.height, weight), + rgba8.width, rgba8.height + ) + + private external fun applyNative( + rgba8: ByteArray, width: Int, height: Int, weight: Float + ): ByteArray +} diff --git a/plugin/lib/src/image_processor.dart b/plugin/lib/src/image_processor.dart index 1a61392a..4720cb8a 100644 --- a/plugin/lib/src/image_processor.dart +++ b/plugin/lib/src/image_processor.dart @@ -1,8 +1,49 @@ import 'dart:async'; import 'package:flutter/services.dart'; +import 'package:nc_photos_plugin/src/image.dart'; import 'package:nc_photos_plugin/src/k.dart' as k; +abstract class ImageFilter { + Map toJson(); +} + +class ColorBrightnessFilter extends _SingleWeightFilter { + const ColorBrightnessFilter(double weight) : super("brightness", weight); +} + +class ColorContrastFilter extends _SingleWeightFilter { + const ColorContrastFilter(double weight) : super("contrast", weight); +} + +class ColorWhitePointFilter extends _SingleWeightFilter { + const ColorWhitePointFilter(double weight) : super("whitePoint", weight); +} + +class ColorHighlightFilter extends _SingleWeightFilter { + const ColorHighlightFilter(double weight) : super("highlight", weight); +} + +class ColorShadowFilter extends _SingleWeightFilter { + const ColorShadowFilter(double weight) : super("shadow", weight); +} + +class ColorBlackPointFilter extends _SingleWeightFilter { + const ColorBlackPointFilter(double weight) : super("blackPoint", weight); +} + +class ColorSaturationFilter extends _SingleWeightFilter { + const ColorSaturationFilter(double weight) : super("saturation", weight); +} + +class ColorWarmthFilter extends _SingleWeightFilter { + const ColorWarmthFilter(double weight) : super("warmth", weight); +} + +class ColorTintFilter extends _SingleWeightFilter { + const ColorTintFilter(double weight) : super("tint", weight); +} + class ImageProcessor { static Future zeroDce( String fileUrl, @@ -72,6 +113,48 @@ class ImageProcessor { "weight": weight, }); + static Future colorFilter( + String fileUrl, + String filename, + int maxWidth, + int maxHeight, + List filters, { + Map? headers, + }) => + _methodChannel.invokeMethod("colorFilter", { + "fileUrl": fileUrl, + "headers": headers, + "filename": filename, + "maxWidth": maxWidth, + "maxHeight": maxHeight, + "filters": filters.map((f) => f.toJson()).toList(), + }); + + static Future filterPreview( + Rgba8Image img, + List filters, + ) async { + final result = await _methodChannel + .invokeMethod("filterPreview", { + "rgba8": img.toJson(), + "filters": filters.map((f) => f.toJson()).toList(), + }); + return Rgba8Image.fromJson(result!.cast()); + } + static const _methodChannel = MethodChannel("${k.libId}/image_processor_method"); } + +class _SingleWeightFilter implements ImageFilter { + const _SingleWeightFilter(this.type, this.weight); + + @override + toJson() => { + "type": type, + "weight": weight, + }; + + final String type; + final double weight; +}