Change orientation of an image in editor

This commit is contained in:
Ming Ming 2022-09-06 14:36:27 +08:00
parent 08f372d691
commit 7ffe4a55b9
9 changed files with 624 additions and 9 deletions

View file

@ -1326,6 +1326,26 @@
"@imageEditTitle": {
"description": "Title of the image editor"
},
"imageEditToolbarColorLabel": "Color",
"@imageEditToolbarColorLabel": {
"description": "Label of the color tools. These can be used to adjust the color of an image"
},
"imageEditToolbarTransformLabel": "Transform",
"@imageEditToolbarTransformLabel": {
"description": "Label of the transformation tools. These can be used to transform an image, e.g., rotate it"
},
"imageEditTransformOrientation": "Orientation",
"@imageEditTransformOrientation": {
"description": "Change the orientation of the image, 90 degree per step"
},
"imageEditTransformOrientationClockwise": "cw",
"@imageEditTransformOrientationClockwise": {
"description": "Indicate a clockwise rotation. This text must be short as there's only minimal space"
},
"imageEditTransformOrientationCounterclockwise": "ccw",
"@imageEditTransformOrientationCounterclockwise": {
"description": "Indicate a counterclockwise rotation. This text must be short as there's only minimal space"
},
"categoriesLabel": "Categories",
"searchLandingPeopleListEmptyText": "Press help to learn how to setup",
"@searchLandingPeopleListEmptyText": {
@ -1414,6 +1434,7 @@
"@collectionPlacesLabel": {
"description": "Browse photos grouped by place"
},
"errorUnauthenticated": "Unauthenticated access. Please sign-in again if the problem continues",
"@errorUnauthenticated": {
"description": "Error message when server responds with HTTP401"

View file

@ -129,6 +129,11 @@
"imageEditColorWarmth",
"imageEditColorTint",
"imageEditTitle",
"imageEditToolbarColorLabel",
"imageEditToolbarTransformLabel",
"imageEditTransformOrientation",
"imageEditTransformOrientationClockwise",
"imageEditTransformOrientationCounterclockwise",
"categoriesLabel",
"searchLandingPeopleListEmptyText",
"searchLandingCategoryVideosLabel",
@ -298,6 +303,11 @@
"imageEditColorWarmth",
"imageEditColorTint",
"imageEditTitle",
"imageEditToolbarColorLabel",
"imageEditToolbarTransformLabel",
"imageEditTransformOrientation",
"imageEditTransformOrientationClockwise",
"imageEditTransformOrientationCounterclockwise",
"categoriesLabel",
"searchLandingPeopleListEmptyText",
"searchLandingCategoryVideosLabel",
@ -348,6 +358,11 @@
"imageEditColorWarmth",
"imageEditColorTint",
"imageEditTitle",
"imageEditToolbarColorLabel",
"imageEditToolbarTransformLabel",
"imageEditTransformOrientation",
"imageEditTransformOrientationClockwise",
"imageEditTransformOrientationCounterclockwise",
"categoriesLabel",
"searchLandingPeopleListEmptyText",
"searchLandingCategoryVideosLabel",
@ -379,6 +394,11 @@
"settingsMemoriesRangeTitle",
"settingsMemoriesRangeValueText",
"rootPickerSkipConfirmationDialogContent2",
"imageEditToolbarColorLabel",
"imageEditToolbarTransformLabel",
"imageEditTransformOrientation",
"imageEditTransformOrientationClockwise",
"imageEditTransformOrientationCounterclockwise",
"showAllButtonLabel",
"gpsPlaceText",
"gpsPlaceAboutDialogTitle",
@ -391,6 +411,11 @@
"settingsPhotosPageTitle",
"settingsMemoriesRangeTitle",
"settingsMemoriesRangeValueText",
"imageEditToolbarColorLabel",
"imageEditToolbarTransformLabel",
"imageEditTransformOrientation",
"imageEditTransformOrientationClockwise",
"imageEditTransformOrientationCounterclockwise",
"gpsPlaceText",
"gpsPlaceAboutDialogTitle",
"gpsPlaceAboutDialogContent",
@ -447,6 +472,11 @@
"imageEditColorWarmth",
"imageEditColorTint",
"imageEditTitle",
"imageEditToolbarColorLabel",
"imageEditToolbarTransformLabel",
"imageEditTransformOrientation",
"imageEditTransformOrientationClockwise",
"imageEditTransformOrientationCounterclockwise",
"categoriesLabel",
"searchLandingPeopleListEmptyText",
"searchLandingCategoryVideosLabel",
@ -538,6 +568,11 @@
"imageEditColorWarmth",
"imageEditColorTint",
"imageEditTitle",
"imageEditToolbarColorLabel",
"imageEditToolbarTransformLabel",
"imageEditTransformOrientation",
"imageEditTransformOrientationClockwise",
"imageEditTransformOrientationCounterclockwise",
"categoriesLabel",
"searchLandingPeopleListEmptyText",
"searchLandingCategoryVideosLabel",
@ -608,6 +643,11 @@
"imageEditColorWarmth",
"imageEditColorTint",
"imageEditTitle",
"imageEditToolbarColorLabel",
"imageEditToolbarTransformLabel",
"imageEditTransformOrientation",
"imageEditTransformOrientationClockwise",
"imageEditTransformOrientationCounterclockwise",
"categoriesLabel",
"searchLandingPeopleListEmptyText",
"searchLandingCategoryVideosLabel",
@ -678,6 +718,11 @@
"imageEditColorWarmth",
"imageEditColorTint",
"imageEditTitle",
"imageEditToolbarColorLabel",
"imageEditToolbarTransformLabel",
"imageEditTransformOrientation",
"imageEditTransformOrientationClockwise",
"imageEditTransformOrientationCounterclockwise",
"categoriesLabel",
"searchLandingPeopleListEmptyText",
"searchLandingCategoryVideosLabel",
@ -748,6 +793,11 @@
"imageEditColorWarmth",
"imageEditColorTint",
"imageEditTitle",
"imageEditToolbarColorLabel",
"imageEditToolbarTransformLabel",
"imageEditTransformOrientation",
"imageEditTransformOrientationClockwise",
"imageEditTransformOrientationCounterclockwise",
"categoriesLabel",
"searchLandingPeopleListEmptyText",
"searchLandingCategoryVideosLabel",
@ -818,6 +868,11 @@
"imageEditColorWarmth",
"imageEditColorTint",
"imageEditTitle",
"imageEditToolbarColorLabel",
"imageEditToolbarTransformLabel",
"imageEditTransformOrientation",
"imageEditTransformOrientationClockwise",
"imageEditTransformOrientationCounterclockwise",
"categoriesLabel",
"searchLandingPeopleListEmptyText",
"searchLandingCategoryVideosLabel",

View file

@ -15,6 +15,7 @@ import 'package:nc_photos/theme.dart';
import 'package:nc_photos/url_launcher_util.dart';
import 'package:nc_photos/widget/handler/permission_handler.dart';
import 'package:nc_photos/widget/image_editor/color_toolbar.dart';
import 'package:nc_photos/widget/image_editor/transform_toolbar.dart';
import 'package:nc_photos_plugin/nc_photos_plugin.dart';
class ImageEditorArguments {
@ -120,13 +121,24 @@ class _ImageEditorState extends State<ImageEditor> {
)
: Container(),
),
ColorToolbar(
initialState: _colorFilters,
onActiveFiltersChanged: (colorFilters) {
_colorFilters = colorFilters.toList();
_applyFilters();
},
),
if (_activeTool == _ToolType.color)
ColorToolbar(
initialState: _colorFilters,
onActiveFiltersChanged: (colorFilters) {
_colorFilters = colorFilters.toList();
_applyFilters();
},
)
else if (_activeTool == _ToolType.transform)
TransformToolbar(
initialState: _transformFilters,
onActiveFiltersChanged: (transformFilters) {
_transformFilters = transformFilters.toList();
_applyFilters();
},
),
const SizedBox(height: 4),
_buildToolBar(context),
],
),
),
@ -156,6 +168,41 @@ class _ImageEditorState extends State<ImageEditor> {
],
);
Widget _buildToolBar(BuildContext context) {
return Align(
alignment: AlignmentDirectional.centerStart,
child: SingleChildScrollView(
scrollDirection: Axis.horizontal,
child: Row(
children: [
const SizedBox(width: 16),
_ToolButton(
icon: Icons.palette_outlined,
label: L10n.global().imageEditToolbarColorLabel,
isSelected: _activeTool == _ToolType.color,
onPressed: () {
setState(() {
_activeTool = _ToolType.color;
});
},
),
_ToolButton(
icon: Icons.transform_outlined,
label: L10n.global().imageEditToolbarTransformLabel,
isSelected: _activeTool == _ToolType.transform,
onPressed: () {
setState(() {
_activeTool = _ToolType.transform;
});
},
),
const SizedBox(width: 16),
],
),
),
);
}
Future<void> _onBackButton(BuildContext context) async {
if (!_isModified) {
Navigator.of(context).pop();
@ -203,7 +250,10 @@ class _ImageEditorState extends State<ImageEditor> {
}
List<ImageFilter> _buildFilterList() {
return _colorFilters.map((f) => f.toImageFilter()).toList();
return [
..._transformFilters.map((f) => f.toImageFilter()),
..._colorFilters.map((f) => f.toImageFilter()),
];
}
Future<void> _applyFilters() async {
@ -213,11 +263,79 @@ class _ImageEditorState extends State<ImageEditor> {
});
}
bool get _isModified => _colorFilters.isNotEmpty;
bool get _isModified =>
_transformFilters.isNotEmpty || _colorFilters.isNotEmpty;
bool _isDoneInit = false;
late final Rgba8Image _src;
Rgba8Image? _dst;
var _activeTool = _ToolType.color;
var _colorFilters = <ColorArguments>[];
var _transformFilters = <TransformArguments>[];
}
enum _ToolType {
color,
transform,
}
class _ToolButton extends StatelessWidget {
const _ToolButton({
Key? key,
required this.icon,
required this.label,
required this.onPressed,
this.isSelected = false,
}) : super(key: key);
@override
build(BuildContext context) {
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 4),
child: ClipRRect(
borderRadius: const BorderRadius.all(Radius.circular(24)),
child: Material(
type: MaterialType.transparency,
child: InkWell(
onTap: onPressed,
child: Container(
decoration: BoxDecoration(
color: isSelected ? Colors.white24 : null,
// borderRadius: const BorderRadius.all(Radius.circular(24)),
),
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
alignment: Alignment.center,
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
icon,
color: isSelected
? Colors.white
: AppTheme.unfocusedIconColorDark,
size: 18,
),
const SizedBox(width: 4),
Text(
label,
style: TextStyle(
color: isSelected
? Colors.white
: AppTheme.unfocusedIconColorDark,
),
),
],
),
),
),
),
),
);
}
final IconData icon;
final String label;
final VoidCallback? onPressed;
final bool isSelected;
}

View file

@ -0,0 +1,253 @@
import 'package:flutter/material.dart';
import 'package:nc_photos/app_localizations.dart';
import 'package:nc_photos/theme.dart';
import 'package:nc_photos/widget/image_editor/toolbar_button.dart';
import 'package:nc_photos_plugin/nc_photos_plugin.dart';
enum TransformToolType {
orientation,
}
abstract class TransformArguments {
ImageFilter toImageFilter();
TransformToolType _getToolType();
}
class TransformToolbar extends StatefulWidget {
const TransformToolbar({
Key? key,
required this.initialState,
required this.onActiveFiltersChanged,
}) : super(key: key);
@override
createState() => _TransformToolbarState();
final List<TransformArguments> initialState;
final ValueChanged<Iterable<TransformArguments>> onActiveFiltersChanged;
}
class _TransformToolbarState extends State<TransformToolbar> {
@override
initState() {
super.initState();
for (final s in widget.initialState) {
_filters[s._getToolType()] = s;
}
}
@override
build(BuildContext context) => Column(
children: [
_buildFilterOption(context),
_buildFilterBar(context),
],
);
Widget _buildFilterOption(BuildContext context) {
Widget? child;
switch (_selectedFilter) {
case TransformToolType.orientation:
child = _buildOrientationOption(context);
break;
case null:
child = null;
break;
}
return Container(
height: 80,
alignment: Alignment.bottomCenter,
child: child,
);
}
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),
ToolbarButton(
icon: Icons.rotate_90_degrees_ccw_outlined,
label: L10n.global().imageEditTransformOrientation,
onPressed: _onOrientationPressed,
isSelected: _selectedFilter == TransformToolType.orientation,
activationOrder:
_filters.containsKey(TransformToolType.orientation)
? -1
: null,
),
const SizedBox(width: 16),
],
),
),
),
);
}
Widget _buildOrientationOption(BuildContext context) {
final value =
(_filters[TransformToolType.orientation] as _OrientationArguments)
.value;
return Padding(
padding: const EdgeInsets.all(8),
child: Wrap(
children: [
Flex(
direction: Axis.horizontal,
children: [
Flexible(
flex: 1,
fit: FlexFit.tight,
child: _OrientationButton(
label:
"180\n${L10n.global().imageEditTransformOrientationCounterclockwise}",
isSelected: value == 180,
onPressed: () => _onOrientationOptionPressed(180),
),
),
Flexible(
flex: 1,
fit: FlexFit.tight,
child: _OrientationButton(
label:
"90\n${L10n.global().imageEditTransformOrientationCounterclockwise}",
isSelected: value == 90,
onPressed: () => _onOrientationOptionPressed(90),
),
),
Flexible(
flex: 1,
fit: FlexFit.tight,
child: _OrientationButton(
label: "0\n ",
isSelected: value == 0,
onPressed: () => _onOrientationOptionPressed(0),
),
),
Flexible(
flex: 1,
fit: FlexFit.tight,
child: _OrientationButton(
label:
"90\n${L10n.global().imageEditTransformOrientationClockwise}",
isSelected: value == -90,
onPressed: () => _onOrientationOptionPressed(-90),
),
),
Flexible(
flex: 1,
fit: FlexFit.tight,
child: _OrientationButton(
label:
"180\n${L10n.global().imageEditTransformOrientationClockwise}",
isSelected: value == -180,
onPressed: () => _onOrientationOptionPressed(-180),
),
),
],
),
],
),
);
}
void _onFilterPressed(TransformToolType type, TransformArguments defArgs) {
if (_selectedFilter == type) {
// deactivate filter
setState(() {
_selectedFilter = null;
_filters.remove(type);
});
} else {
setState(() {
_selectedFilter = type;
_filters[type] ??= defArgs;
});
}
_notifyFiltersChanged();
}
void _onOrientationPressed() => _onFilterPressed(
TransformToolType.orientation, const _OrientationArguments(0));
void _onOrientationOptionPressed(int value) {
setState(() {
_filters[TransformToolType.orientation] = _OrientationArguments(value);
});
_notifyFiltersChanged();
}
void _notifyFiltersChanged() {
widget.onActiveFiltersChanged.call(_filters.values);
}
final _filters = <TransformToolType, TransformArguments>{};
TransformToolType? _selectedFilter;
}
class _OrientationArguments implements TransformArguments {
const _OrientationArguments(this.value);
@override
toImageFilter() => TransformOrientationFilter(value);
@override
_getToolType() => TransformToolType.orientation;
final int value;
}
class _OrientationButton extends StatelessWidget {
const _OrientationButton({
Key? key,
required this.label,
required this.onPressed,
this.isSelected = false,
}) : super(key: key);
@override
build(BuildContext context) {
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 4),
child: ClipRRect(
borderRadius: const BorderRadius.all(Radius.circular(24)),
child: Material(
type: MaterialType.transparency,
child: InkWell(
onTap: onPressed,
child: Container(
decoration: BoxDecoration(
color: isSelected
? AppTheme.primarySwatchDark[500]!.withOpacity(0.7)
: null,
// borderRadius: const BorderRadius.all(Radius.circular(24)),
),
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
alignment: Alignment.center,
child: Text(
label,
textAlign: TextAlign.center,
style: TextStyle(
color: isSelected
? Colors.white
: AppTheme.unfocusedIconColorDark,
),
),
),
),
),
),
);
}
final String label;
final VoidCallback? onPressed;
final bool isSelected;
}

View file

@ -38,6 +38,7 @@ add_library( # Sets the name of the library.
filter/contrast.cpp
filter/curve.cpp
filter/hslhsv.cpp
filter/orientation.cpp
filter/saturation.cpp
filter/tint.cpp
filter/warmth.cpp

View file

@ -0,0 +1,136 @@
#include <cstdint>
#include <exception>
#include <jni.h>
#include <vector>
#include "../exception.h"
#include "../log.h"
#include "../util.h"
using namespace plugin;
using namespace std;
namespace {
class Orientation {
public:
std::vector<uint8_t> apply(const uint8_t *rgba8, const size_t width,
const size_t height, const int degree);
private:
std::vector<uint8_t> apply90Ccw(const uint8_t *rgba8, const size_t width,
const size_t height);
std::vector<uint8_t> apply90Cw(const uint8_t *rgba8, const size_t width,
const size_t height);
std::vector<uint8_t> apply180(const uint8_t *rgba8, const size_t width,
const size_t height);
static constexpr const char *TAG = "Orientation";
};
} // namespace
extern "C" JNIEXPORT jbyteArray JNICALL
Java_com_nkming_nc_1photos_plugin_image_1processor_Orientation_applyNative(
JNIEnv *env, jobject *thiz, jbyteArray rgba8, jint width, jint height,
jint degree) {
try {
initOpenMp();
RaiiContainer<jbyte> cRgba8(
[&]() { return env->GetByteArrayElements(rgba8, nullptr); },
[&](jbyte *obj) {
env->ReleaseByteArrayElements(rgba8, obj, JNI_ABORT);
});
const auto result = Orientation().apply(
reinterpret_cast<uint8_t *>(cRgba8.get()), width, height, degree);
auto resultAry = env->NewByteArray(result.size());
env->SetByteArrayRegion(resultAry, 0, result.size(),
reinterpret_cast<const int8_t *>(result.data()));
return resultAry;
} catch (const exception &e) {
throwJavaException(env, e.what());
return nullptr;
}
}
namespace {
vector<uint8_t> Orientation::apply(const uint8_t *rgba8, const size_t width,
const size_t height, const int degree) {
LOGI(TAG, "[apply] degree: %d", degree);
if (degree == 0) {
// shortcut
return vector<uint8_t>(rgba8, rgba8 + width * height * 4);
}
if (degree == 90) {
return apply90Ccw(rgba8, width, height);
} else if (degree == -90) {
return apply90Cw(rgba8, width, height);
} else {
return apply180(rgba8, width, height);
}
}
vector<uint8_t> Orientation::apply90Ccw(const uint8_t *rgba8,
const size_t width,
const size_t height) {
vector<uint8_t> output(width * height * 4);
#pragma omp parallel for
for (size_t y = 0; y < height; ++y) {
const auto yI = y * width * 4;
for (size_t x = 0; x < width; ++x) {
const auto p = x * 4 + yI;
const auto desY = width - x - 1;
const auto desX = y;
const auto desP = (desY * height + desX) * 4;
output[desP + 0] = rgba8[p + 0];
output[desP + 1] = rgba8[p + 1];
output[desP + 2] = rgba8[p + 2];
output[desP + 3] = rgba8[p + 3];
}
}
return output;
}
vector<uint8_t> Orientation::apply90Cw(const uint8_t *rgba8, const size_t width,
const size_t height) {
vector<uint8_t> output(width * height * 4);
#pragma omp parallel for
for (size_t y = 0; y < height; ++y) {
const auto yI = y * width * 4;
for (size_t x = 0; x < width; ++x) {
const auto p = x * 4 + yI;
const auto desY = width - x - 1;
const auto desX = height - y - 1;
const auto desP = (desY * height + desX) * 4;
output[desP + 0] = rgba8[p + 0];
output[desP + 1] = rgba8[p + 1];
output[desP + 2] = rgba8[p + 2];
output[desP + 3] = rgba8[p + 3];
}
}
return output;
}
vector<uint8_t> Orientation::apply180(const uint8_t *rgba8, const size_t width,
const size_t height) {
vector<uint8_t> output(width * height * 4);
#pragma omp parallel for
for (size_t y = 0; y < height; ++y) {
const auto yI = y * width * 4;
for (size_t x = 0; x < width; ++x) {
const auto p = x * 4 + yI;
const auto desY = height - y - 1;
const auto desX = width - x - 1;
const auto desP = (desY * width + desX) * 4;
output[desP + 0] = rgba8[p + 0];
output[desP + 1] = rgba8[p + 1];
output[desP + 2] = rgba8[p + 2];
output[desP + 3] = rgba8[p + 3];
}
}
return output;
}
} // namespace

View file

@ -237,6 +237,7 @@ interface ImageFilter {
"saturation" -> Saturation((json["weight"] as Double).toFloat())
"warmth" -> Warmth((json["weight"] as Double).toFloat())
"tint" -> Tint((json["weight"] as Double).toFloat())
"orientation" -> Orientation(json["degree"] as Int)
else -> throw IllegalArgumentException(
"Unknown type: ${json["type"]}"
)

View file

@ -0,0 +1,18 @@
package com.nkming.nc_photos.plugin.image_processor
import com.nkming.nc_photos.plugin.ImageFilter
import kotlin.math.abs
class Orientation(val degree: Int) : ImageFilter {
override fun apply(rgba8: Rgba8Image): Rgba8Image {
val data = applyNative(rgba8.pixel, rgba8.width, rgba8.height, degree)
return Rgba8Image(
data, if (abs(degree) == 90) rgba8.height else rgba8.width,
if (abs(degree) == 90) rgba8.width else rgba8.height
)
}
private external fun applyNative(
rgba8: ByteArray, width: Int, height: Int, degree: Int
): ByteArray
}

View file

@ -44,6 +44,18 @@ class ColorTintFilter extends _SingleWeightFilter {
const ColorTintFilter(double weight) : super("tint", weight);
}
class TransformOrientationFilter implements ImageFilter {
const TransformOrientationFilter(this.degree);
@override
toJson() => {
"type": "orientation",
"degree": degree,
};
final int degree;
}
class ImageProcessor {
static Future<void> zeroDce(
String fileUrl,