Skip to content

Commit 89cf8a3

Browse files
authored
Merge pull request #61 from MobileRoboticsSkoltech/recsync
RecSync functionality
2 parents 848db89 + 5339388 commit 89cf8a3

File tree

72 files changed

+4967
-310
lines changed

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

72 files changed

+4967
-310
lines changed

README.md

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,30 @@ This project is based on [Open Camera](https://opencamera.org.uk/) — a popul
4242
- *Note: phase, which is returned by* ```start_recording``` *method, can be used to perform synchronization with external devices*
4343
![remote control methods](https://www.websequencediagrams.com/files/render?link=6txhpHrdgaebT4DYz2C3SaEQjHM1esYDkJZJvPZcgCJHbRAg3c8hqcJYgOmGirze)
4444

45+
### Synchronized recording on multiple smartphones (RecSync)
46+
47+
**Important**: smartphones are required to support real-time timestamping to be correctly synchronized. This can be checked on the preview message when RecSync is enabled ("Timestamp source" should be "realtime").
48+
![screenshot timestamp source](https://i.imgur.com/vQHufyV.png)
49+
50+
**Leader smartphone setup:**
51+
- Start a **Wi-Fi hotspot**
52+
- Open OpenCamera Sensors, go to preferences - "RecSync settings..." and enable the **"Use RecSync"** switch
53+
- (Optional) Enable **phase alignment** option if synchronization precision better than half of a frame duration is required
54+
- (Optional) Choose which camera settings will be broadcasted to client smartphones in the **"Sync settings"** section
55+
- Switch to video, adjust the camera settings as needed and press the **settings synchronization button**
56+
- Wait for client smartphones to connect if needed
57+
- (Optional) If phase alignment was enabled, press the **phase alignment button** to start the alignment and wait for it to finish ("Phase error" on the preview indicates how much the current phase differs from the targeted one -- when it becomes green, the phase is considered aligned)
58+
- **Start a video recording**
59+
60+
![screenshot_recsync_buttons](https://i.imgur.com/iQS8zpc.png)
61+
62+
**Client smartphones setup:**
63+
- **Connect** to the leader's Wi-Fi hotspot
64+
- Open OpenCamera Sensors, go to preferences - "RecSync settings..." and enable the **"Use RecSync"** switch
65+
- Adjust the camera settings as needed (the ones that will not be broadcast by the leader) and wait for the leader to start the recording
66+
67+
_Note: the phase needs to be re-aligned before every recording._
68+
4569
## Good practices for data recording
4670

4771
- When recording video with audio recording enabled, MediaRecorder adds extra frames to the video to match the sound.
Lines changed: 242 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,242 @@
1+
/**
2+
* Copyright 2019 The Google Research Authors.
3+
* <p>
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
* <p>
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
* <p>
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
* <p>
16+
* Modifications copyright (C) 2021 Mobile Robotics Lab. at Skoltech.
17+
*/
18+
19+
package com.googleresearch.capturesync;
20+
21+
import static com.googleresearch.capturesync.softwaresync.SyncConstants.MAX_ITERATIONS;
22+
import static com.googleresearch.capturesync.softwaresync.SyncConstants.PHASE_SETTLE_DELAY_MS;
23+
24+
import android.content.Context;
25+
import android.hardware.camera2.CameraAccessException;
26+
import android.os.Build;
27+
import android.os.Handler;
28+
import android.util.Log;
29+
import android.util.Pair;
30+
31+
import androidx.annotation.RequiresApi;
32+
33+
import com.googleresearch.capturesync.softwaresync.TimeUtils;
34+
import com.googleresearch.capturesync.softwaresync.phasealign.PhaseAligner;
35+
import com.googleresearch.capturesync.softwaresync.phasealign.PhaseConfig;
36+
import com.googleresearch.capturesync.softwaresync.phasealign.PhaseResponse;
37+
38+
import net.sourceforge.opencamera.R;
39+
import net.sourceforge.opencamera.ToastBoxer;
40+
import net.sourceforge.opencamera.cameracontroller.CameraController;
41+
import net.sourceforge.opencamera.cameracontroller.CameraController2;
42+
import net.sourceforge.opencamera.preview.Preview;
43+
44+
/**
45+
* Calculates and adjusts camera phase by inserting frames of varying exposure lengths.
46+
*
47+
* <p>Phase alignment is an iterative process. Running for more iterations results in higher
48+
* accuracy up to the stability of the camera and the accuracy of the phase alignment configuration
49+
* values.
50+
*/
51+
public class PhaseAlignController {
52+
private static final String TAG = "PhaseAlignController";
53+
54+
private final Context mContext;
55+
private final Preview mPreview;
56+
private final ToastBoxer mToastBoxer;
57+
58+
private final Handler mHandler;
59+
private final Object mLock = new Object();
60+
private Runnable mOnFinished;
61+
62+
private boolean mInAlignState = false;
63+
private boolean mStopAlign = false;
64+
private boolean mWasAligned = false;
65+
66+
private CameraController2 mCameraController;
67+
68+
private PhaseAligner mPhaseAligner;
69+
private final PhaseConfig mPhaseConfig;
70+
private PhaseResponse mLatestResponse;
71+
72+
public PhaseAlignController(PhaseConfig config, Context context, Preview preview, ToastBoxer toastBoxer) {
73+
mHandler = new Handler();
74+
mPhaseConfig = config;
75+
mPhaseAligner = new PhaseAligner(config);
76+
Log.v(TAG, "Loaded phase align config.");
77+
mContext = context;
78+
mPreview = preview;
79+
mToastBoxer = toastBoxer;
80+
}
81+
82+
protected void setPeriodNs(long periodNs) {
83+
mPhaseConfig.setPeriodNs(periodNs);
84+
mPhaseAligner = new PhaseAligner(mPhaseConfig);
85+
}
86+
87+
/**
88+
* Update the latest phase response from the latest frame timestamp to keep track of phase.
89+
*
90+
* <p>The timestamp is nanoseconds in the synchronized leader clock domain.
91+
*
92+
* @return phase of timestamp in nanoseconds in the same domain as given.
93+
*/
94+
public long updateCaptureTimestamp(long timestampNs) {
95+
// TODO(samansaari) : Rename passTimestamp -> updateCaptureTimestamp or similar in softwaresync.
96+
mLatestResponse = mPhaseAligner.passTimestamp(timestampNs);
97+
return mLatestResponse.phaseNs();
98+
}
99+
100+
/**
101+
* Starts phase alignment if it is not running.
102+
* <p>
103+
* Needs to be stopped with {@link #mStopAlign} if {@link CameraController} changes during the
104+
* alignment.
105+
*
106+
* @param onFinished a {@link Runnable} to be called when the alignment is finished (regardless
107+
* of was if successful or not). If phase alignment is already running, this
108+
* parameter is ignored.
109+
*/
110+
@RequiresApi(api = Build.VERSION_CODES.LOLLIPOP)
111+
public void startAlign(Runnable onFinished) {
112+
mPreview.showToast(mToastBoxer, R.string.phase_alignment_started);
113+
114+
final CameraController currentCameraController = mPreview.getCameraController();
115+
if (currentCameraController == null) {
116+
throw new IllegalStateException("Alignment start failed: camera is not open.");
117+
}
118+
if (!(currentCameraController instanceof CameraController2)) {
119+
throw new IllegalStateException("Alignment start failed: not using Camera2 API.");
120+
}
121+
mCameraController = (CameraController2) currentCameraController;
122+
123+
synchronized (mLock) {
124+
if (mInAlignState) {
125+
Log.i(TAG, "startAlign() called while already aligning.");
126+
return;
127+
}
128+
mInAlignState = true;
129+
mStopAlign = false;
130+
mOnFinished = onFinished;
131+
// Start inserting frames every {@code PHASE_SETTLE_DELAY_MS} ms to try and push the phase to
132+
// the goal phase. Stop after aligned to threshold or after {@code MAX_ITERATIONS}.
133+
mHandler.post(() -> work(MAX_ITERATIONS));
134+
}
135+
}
136+
137+
@RequiresApi(api = Build.VERSION_CODES.LOLLIPOP)
138+
private void work(int iterationsLeft) {
139+
if (mLatestResponse == null) {
140+
onAlignmentFinished(false);
141+
Log.e(TAG, "Aligning failed: no timestamps available, latest response is null.");
142+
return;
143+
}
144+
145+
// Check if Aligned / Not Aligned but able to iterate / Ran out of iterations.
146+
if (mLatestResponse.isAligned()) { // Aligned.
147+
Log.i(
148+
TAG,
149+
String.format(
150+
"Reached: Current Phase: %.3f ms, Diff: %.3f ms",
151+
mLatestResponse.phaseNs() * 1e-6f, mLatestResponse.diffFromGoalNs() * 1e-6f));
152+
153+
onAlignmentFinished(true);
154+
Log.d(TAG, "Aligned.");
155+
} else if (!mLatestResponse.isAligned() && iterationsLeft > 0) { // Not aligned but able to run another alignment iteration.
156+
if (mStopAlign) {
157+
onAlignmentFinished(false);
158+
Log.d(TAG, "Stopping alignment as received a command to.");
159+
return;
160+
}
161+
162+
doPhaseAlignStep();
163+
Log.v(TAG, "Queued another phase align step.");
164+
// TODO (samansari) : Replace this brittle delay-based solution to a response-based one.
165+
mHandler.postDelayed(
166+
() -> work(iterationsLeft - 1), PHASE_SETTLE_DELAY_MS); // Try again after it settles.
167+
} else { // Reached max iterations before aligned.
168+
Log.i(
169+
TAG,
170+
String.format(
171+
"Failed to Align, Stopping at: Current Phase: %.3f ms, Diff: %.3f ms",
172+
mLatestResponse.phaseNs() * 1e-6f, mLatestResponse.diffFromGoalNs() * 1e-6f));
173+
174+
onAlignmentFinished(false);
175+
Log.d(TAG, "Finishing alignment, reached max iterations.");
176+
}
177+
}
178+
179+
/**
180+
* Submit a frame with a specific exposure to offset future frames and align phase.
181+
*/
182+
@RequiresApi(api = Build.VERSION_CODES.LOLLIPOP)
183+
private void doPhaseAlignStep() {
184+
Log.i(
185+
TAG,
186+
String.format(
187+
"Current Phase: %.3f ms, Diff: %.3f ms, inserting frame exposure %.6f ms, lower bound"
188+
+ " %.6f ms.",
189+
mLatestResponse.phaseNs() * 1e-6f,
190+
mLatestResponse.diffFromGoalNs() * 1e-6f,
191+
mLatestResponse.exposureTimeToShiftNs() * 1e-6f,
192+
mPhaseAligner.getConfig().minExposureNs() * 1e-6f));
193+
194+
try {
195+
mCameraController.injectFrameWithExposure(mLatestResponse.exposureTimeToShiftNs());
196+
} catch (CameraAccessException e) {
197+
Log.e(TAG, "Frame injection failed.", e);
198+
}
199+
}
200+
201+
private void onAlignmentFinished(boolean wasAligned) {
202+
synchronized (mLock) {
203+
mInAlignState = false;
204+
}
205+
mWasAligned = wasAligned;
206+
if (mOnFinished != null) mOnFinished.run();
207+
mPreview.showToast(mToastBoxer, wasAligned ? R.string.phase_alignment_succeeded : R.string.phase_alignment_failed);
208+
}
209+
210+
/**
211+
* Stop phase alignment if it is running.
212+
*/
213+
public void stopAlign() {
214+
if (mInAlignState) mStopAlign = true;
215+
}
216+
217+
/**
218+
* Indicates whether the last finished alignment attempt was successful.
219+
*
220+
* @return true if the last alignment attempt was successful, false if it wasn't or no attempts
221+
* were made.
222+
*/
223+
public boolean wasAligned() {
224+
return mWasAligned;
225+
}
226+
227+
/**
228+
* The current phase error description, if it is available.
229+
*
230+
* @return a {@link Pair} of a string containing the current phase error and the current
231+
* alignment status if phase error is determined, or null otherwise.
232+
*/
233+
public Pair<String, Boolean> getPhaseError() {
234+
if (mLatestResponse != null) {
235+
final String phaseError = mContext.getString(R.string.phase_error,
236+
TimeUtils.nanosToMillis((double) mLatestResponse.diffFromGoalNs()));
237+
return new Pair<>(phaseError, mLatestResponse.isAligned());
238+
} else {
239+
return null;
240+
}
241+
}
242+
}

0 commit comments

Comments
 (0)