Skip to content

Commit

Permalink
Add more camera rotation (90°, 180°, 270°) (#1823)
Browse files Browse the repository at this point in the history
  • Loading branch information
callemand authored Jul 10, 2023
1 parent 9ae2c3c commit 7805e29
Show file tree
Hide file tree
Showing 9 changed files with 230 additions and 35 deletions.
7 changes: 5 additions & 2 deletions front/src/config/i18n/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -429,9 +429,12 @@
"testConnectionButton": "Test connection",
"saveButton": "Save",
"deleteButton": "Delete",
"rotate180Label": "180° Rotation",
"saveError": "There was an error saving the camera.",
"testConnectionError": "There was an error while getting the RTSP Flux. Are you sure the provided URL is right and accessible from Gladys instance?"
"testConnectionError": "There was an error while getting the RTSP Flux. Are you sure the provided URL is right and accessible from Gladys instance?",
"rotationO": "No rotation",
"rotation90": "90°",
"rotation18O": "180°",
"rotation27O": "270°"
},
"tasmota": {
"title": "Tasmota",
Expand Down
7 changes: 5 additions & 2 deletions front/src/config/i18n/fr.json
Original file line number Diff line number Diff line change
Expand Up @@ -555,9 +555,12 @@
"testConnectionButton": "Tester",
"saveButton": "Sauvegarder",
"deleteButton": "Supprimer",
"rotate180Label": "Rotation 180°",
"saveError": "Une erreur s'est produite lors de l'enregistrement de la caméra.",
"testConnectionError": "Une erreur s'est produite lors de l'obtention du flux RTSP. Êtes-vous sûr que l'URL fournie est correcte et accessible à partir de l'instance Gladys ?"
"testConnectionError": "Une erreur s'est produite lors de l'obtention du flux RTSP. Êtes-vous sûr que l'URL fournie est correcte et accessible à partir de l'instance Gladys ?",
"rotationO": "Pas de rotation",
"rotation90": "90°",
"rotation18O": "180°",
"rotation27O": "270°"
},
"tasmota": {
"title": "Tasmota",
Expand Down
39 changes: 21 additions & 18 deletions front/src/routes/integration/all/rtsp-camera/RtspCameraBox.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { Component } from 'preact';
import get from 'get-value';
import cx from 'classnames';
import { RequestStatus } from '../../../../utils/consts';
import { DEVICE_POLL_FREQUENCIES } from '../../../../../../server/utils/constants';
import { DEVICE_POLL_FREQUENCIES, DEVICE_ROTATION } from '../../../../../../server/utils/constants';

class RtspCameraBox extends Component {
saveCamera = async () => {
Expand Down Expand Up @@ -69,8 +69,7 @@ class RtspCameraBox extends Component {
this.props.updateCameraUrl(this.props.cameraIndex, e.target.value);
};
updateCameraRotation = e => {
const newValue = e.target.checked ? '1' : '0';
this.props.updateCameraRotation(this.props.cameraIndex, newValue);
this.props.updateCameraRotation(this.props.cameraIndex, e.target.value);
};
updateCameraRoom = e => {
const newRoom = e.target.value === '' ? null : e.target.value;
Expand Down Expand Up @@ -180,20 +179,24 @@ class RtspCameraBox extends Component {
</p>
</div>
<div class="form-group">
<label class="custom-switch">
<input
type="checkbox"
id="cameraRotate"
name="cameraRotate"
class="custom-switch-input"
checked={get(props, 'camera.cameraRotation.value') === '1'}
onClick={this.updateCameraRotation}
/>
<span class="custom-switch-indicator" />
<span class="custom-switch-description">
<Text id="integration.rtspCamera.rotate180Label" />
</span>
</label>
<select
className="form-control"
onChange={this.updateCameraRotation}
value={get(props, 'camera.cameraRotation.value')}
>
<option value={DEVICE_ROTATION.DEGREES_0}>
<Text id={`integration.rtspCamera.rotationO`} />
</option>
<option value={DEVICE_ROTATION.DEGREES_90}>
<Text id={`integration.rtspCamera.rotation90`} />
</option>
<option value={DEVICE_ROTATION.DEGREES_180}>
<Text id={`integration.rtspCamera.rotation18O`} />
</option>
<option value={DEVICE_ROTATION.DEGREES_270}>
<Text id={`integration.rtspCamera.rotation27O`} />
</option>
</select>
</div>
<div class="form-group">
<button onClick={this.testConnection} class="btn btn-info mr-2">
Expand All @@ -202,7 +205,7 @@ class RtspCameraBox extends Component {
<button onClick={this.saveCamera} class="btn btn-success mr-2">
<Text id="integration.rtspCamera.saveButton" />
</button>
<button onClick={this.deleteCamera} class="btn btn-danger mt-4 mt-lg-0">
<button onClick={this.deleteCamera} class="btn btn-danger mt-sm-0 mt-md-0 mt-lg-2 mt-xl-0">
<Text id="integration.rtspCamera.deleteButton" />
</button>
</div>
Expand Down
42 changes: 42 additions & 0 deletions server/migrations/20230628144609-change-rotation-camera.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
const Promise = require('bluebird');
const db = require('../models');
const logger = require('../utils/logger');

module.exports = {
up: async (queryInterface, Sequelize) => {
const service = await db.Service.findOne({
where: {
name: 'rtsp-camera',
},
});
if (service === null) {
return;
}
logger.info(`RstpCamera migration: Found service rtsp-camera = ${service.id}`);
const cameraDevices = await db.Device.findAll({
where: {
service_id: service.id,
},
});
logger.info(`RstpCamera migration: Found ${cameraDevices.length} devices`);
await Promise.each(cameraDevices, async (enedisDevice) => {
const deviceParam = await db.DeviceParam.findOne({
where: {
device_id: enedisDevice.id,
name: 'CAMERA_ROTATION',
},
});
if (deviceParam === null) {
return;
}
if (deviceParam.value === '1') {
logger.info(`RstpCamera migration: Updating device_params ${deviceParam.id} with 180°`);
deviceParam.set({
value: '180',
});
await deviceParam.save();
}
});
},
down: async (queryInterface, Sequelize) => {},
};
18 changes: 15 additions & 3 deletions server/services/rtsp-camera/lib/getImage.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ const fse = require('fs-extra');
const path = require('path');
const logger = require('../../../utils/logger');
const { NotFoundError } = require('../../../utils/coreErrors');
const { DEVICE_ROTATION } = require('../../../utils/constants');

const DEVICE_PARAM_CAMERA_URL = 'CAMERA_URL';
const DEVICE_PARAM_CAMERA_ROTATION = 'CAMERA_ROTATION';
Expand Down Expand Up @@ -41,12 +42,23 @@ async function getImage(device) {
const writeStream = fse.createWriteStream(filePath);
const outputOptions = [
'-vframes 1',
'-vf scale=640:-1', // resize the image with max width = 640
'-qscale:v 15', // Effective range for JPEG is 2-31 with 31 being the worst quality.
];
if (cameraRotationParam.value === '1') {
outputOptions.push('-vf hflip,vflip'); // Rotate 180
switch (cameraRotationParam.value) {
case DEVICE_ROTATION.DEGREES_90:
outputOptions.push('-vf scale=640:-1,transpose=1'); // Rotate 90
break;
case DEVICE_ROTATION.DEGREES_180:
outputOptions.push('-vf scale=640:-1,transpose=1,transpose=1'); // Rotate 180
break;
case DEVICE_ROTATION.DEGREES_270:
outputOptions.push('-vf scale=640:-1,transpose=2'); // Rotate 270
break;
default:
outputOptions.push('-vf scale=640:-1'); // Rotate 0
break;
}

// Send a camera thumbnail to this stream
// Add a timeout to prevent ffmpeg from running forever
this.ffmpeg(cameraUrlParam.value, { timeout: 10 })
Expand Down
22 changes: 17 additions & 5 deletions server/services/rtsp-camera/lib/startStreaming.js
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ const util = require('util');
const randomBytes = util.promisify(require('crypto').randomBytes);
const logger = require('../../../utils/logger');
const { NotFoundError } = require('../../../utils/coreErrors');
const { DEVICE_ROTATION } = require('../../../utils/constants');

const DEVICE_PARAM_CAMERA_URL = 'CAMERA_URL';
const DEVICE_PARAM_CAMERA_ROTATION = 'CAMERA_ROTATION';
Expand Down Expand Up @@ -113,8 +114,6 @@ async function startStreaming(cameraSelector, isGladysGateway, segmentDuration =
'veryfast', // Encoding presets
'-flags',
'+cgop',
'-vf',
'scale=1920:-1', // Full HD resolution
'-r',
'25', // Frames (rate) per second
'-g',
Expand Down Expand Up @@ -150,11 +149,24 @@ async function startStreaming(cameraSelector, isGladysGateway, segmentDuration =
indexFilePath,
];

if (cameraRotationParam.value === '1') {
args.push('-vf'); // Rotate 180
args.push('hflip,vflip');
let cameraRotationArgs = '';
switch (cameraRotationParam.value) {
case DEVICE_ROTATION.DEGREES_90:
cameraRotationArgs = 'scale=1920:-1,transpose=1'; // Full HD resolution & Rotate 90
break;
case DEVICE_ROTATION.DEGREES_180:
cameraRotationArgs = 'scale=1920:-1,transpose=1,transpose=1'; // Full HD resolution & Rotate 180
break;
case DEVICE_ROTATION.DEGREES_270:
cameraRotationArgs = 'scale=1920:-1,transpose=2'; // Full HD resolution & Rotate 270
break;
default:
cameraRotationArgs = 'scale=1920:-1'; // Full HD resolution & Rotate 0
break;
}

args.splice(8, 0, '-vf', cameraRotationArgs);

const options = {
timeout: 5 * 60 * 1000, // 5 minutes
};
Expand Down
77 changes: 75 additions & 2 deletions server/test/services/rtsp-camera/rtspCamera.streaming.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ const { fake, assert: fakeAssert } = require('sinon');
const FfmpegMock = require('./FfmpegMock.test');
const RtspCameraManager = require('../../../services/rtsp-camera/lib');
const { NotFoundError } = require('../../../utils/coreErrors');
const { DEVICE_ROTATION } = require('../../../utils/constants');

const device = {
id: 'a6fb4cb8-ccc2-4234-a752-b25d1eb5ab6b',
Expand Down Expand Up @@ -133,7 +134,7 @@ describe('Camera.streaming', () => {
await rtspCameraManager.stopStreaming('my-camera');
fakeAssert.called(rtspCameraManager.sendCameraFileToGatewayLimited);
});
it('should star with rotation & stop streaming', async () => {
it('should star with 90 rotation & stop streaming', async () => {
const gladysDeviceWithRotation = {
config: {
tempFolder: '/tmp/gladys',
Expand All @@ -149,7 +150,79 @@ describe('Camera.streaming', () => {
},
{
name: 'CAMERA_ROTATION',
value: '1',
value: DEVICE_ROTATION.DEGREES_90,
},
],
}),
},
};
rtspCameraManager = new RtspCameraManager(
gladysDeviceWithRotation,
FfmpegMock,
childProcessMock,
'de051f90-f34a-4fd5-be2e-e502339ec9bc',
);
rtspCameraManager.onNewCameraFile = fake.resolves(null);
const liveStreamingProcess = await rtspCameraManager.startStreaming('my-camera', false, 1);
expect(liveStreamingProcess).to.have.property('camera_folder');
expect(liveStreamingProcess).to.have.property('encryption_key');
await rtspCameraManager.liveActivePing('my-camera');
await rtspCameraManager.stopStreaming('my-camera');
fakeAssert.called(rtspCameraManager.onNewCameraFile);
});
it('should star with 180 rotation & stop streaming', async () => {
const gladysDeviceWithRotation = {
config: {
tempFolder: '/tmp/gladys',
},
device: {
getBySelector: fake.resolves({
id: 'a6fb4cb8-ccc2-4234-a752-b25d1eb5ab6b',
selector: 'my-camera',
params: [
{
name: 'CAMERA_URL',
value: 'test',
},
{
name: 'CAMERA_ROTATION',
value: DEVICE_ROTATION.DEGREES_180,
},
],
}),
},
};
rtspCameraManager = new RtspCameraManager(
gladysDeviceWithRotation,
FfmpegMock,
childProcessMock,
'de051f90-f34a-4fd5-be2e-e502339ec9bc',
);
rtspCameraManager.onNewCameraFile = fake.resolves(null);
const liveStreamingProcess = await rtspCameraManager.startStreaming('my-camera', false, 1);
expect(liveStreamingProcess).to.have.property('camera_folder');
expect(liveStreamingProcess).to.have.property('encryption_key');
await rtspCameraManager.liveActivePing('my-camera');
await rtspCameraManager.stopStreaming('my-camera');
fakeAssert.called(rtspCameraManager.onNewCameraFile);
});
it('should star with 270 rotation & stop streaming', async () => {
const gladysDeviceWithRotation = {
config: {
tempFolder: '/tmp/gladys',
},
device: {
getBySelector: fake.resolves({
id: 'a6fb4cb8-ccc2-4234-a752-b25d1eb5ab6b',
selector: 'my-camera',
params: [
{
name: 'CAMERA_URL',
value: 'test',
},
{
name: 'CAMERA_ROTATION',
value: DEVICE_ROTATION.DEGREES_270,
},
],
}),
Expand Down
44 changes: 41 additions & 3 deletions server/test/services/rtsp-camera/rtspCamera.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ const gladys = {
},
};

const deviceFlipped = {
const deviceRotation90 = {
id: 'a6fb4cb8-ccc2-4234-a752-b25d1eb5ab6c',
selector: 'my-camera',
params: [
Expand All @@ -43,7 +43,37 @@ const deviceFlipped = {
},
{
name: 'CAMERA_ROTATION',
value: '1',
value: '90',
},
],
};

const deviceRotation180 = {
id: 'a6fb4cb8-ccc2-4234-a752-b25d1eb5ab6c',
selector: 'my-camera',
params: [
{
name: 'CAMERA_URL',
value: 'test',
},
{
name: 'CAMERA_ROTATION',
value: '180',
},
],
};

const deviceRotation270 = {
id: 'a6fb4cb8-ccc2-4234-a752-b25d1eb5ab6c',
selector: 'my-camera',
params: [
{
name: 'CAMERA_URL',
value: 'test',
},
{
name: 'CAMERA_ROTATION',
value: '270',
},
],
};
Expand Down Expand Up @@ -107,8 +137,16 @@ describe('RtspCameraManager commands', () => {
const image = await rtspCameraManager.getImage(device);
expect(image).to.equal('image/png;base64,aW1hZ2U=');
});
it('should getImage 90°', async () => {
const image = await rtspCameraManager.getImage(deviceRotation90);
expect(image).to.equal('image/png;base64,aW1hZ2U=');
});
it('should getImage 180°', async () => {
const image = await rtspCameraManager.getImage(deviceFlipped);
const image = await rtspCameraManager.getImage(deviceRotation180);
expect(image).to.equal('image/png;base64,aW1hZ2U=');
});
it('should getImage 270°', async () => {
const image = await rtspCameraManager.getImage(deviceRotation270);
expect(image).to.equal('image/png;base64,aW1hZ2U=');
});
it('should return error', async () => {
Expand Down
Loading

0 comments on commit 7805e29

Please sign in to comment.