support capturing using additional webcam resolutions of fhd and uhd4k

This commit is contained in:
Craig Raw 2025-03-13 08:30:53 +02:00
parent bd5af560ff
commit 3e197eb310
7 changed files with 173 additions and 103 deletions

View file

@ -379,7 +379,7 @@ public class AppController implements Initializable {
openWalletsInNewWindows.selectedProperty().bindBidirectional(openWalletsInNewWindowsProperty);
hideEmptyUsedAddressesProperty.set(Config.get().isHideEmptyUsedAddresses());
hideEmptyUsedAddresses.selectedProperty().bindBidirectional(hideEmptyUsedAddressesProperty);
useHdCameraResolutionProperty.set(Config.get().isHdCapture());
useHdCameraResolutionProperty.set(Config.get().getWebcamResolution() == null || Config.get().getWebcamResolution().isWidescreenAspect());
useHdCameraResolution.selectedProperty().bindBidirectional(useHdCameraResolutionProperty);
mirrorCameraImageProperty.set(Config.get().isMirrorCapture());
mirrorCameraImage.selectedProperty().bindBidirectional(mirrorCameraImageProperty);
@ -944,7 +944,11 @@ public class AppController implements Initializable {
public void useHdCameraResolution(ActionEvent event) {
CheckMenuItem item = (CheckMenuItem)event.getSource();
Config.get().setHdCapture(item.isSelected());
if(Config.get().getWebcamResolution().isStandardAspect() && item.isSelected()) {
Config.get().setWebcamResolution(WebcamResolution.HD);
} else if(Config.get().getWebcamResolution().isWidescreenAspect() && !item.isSelected()) {
Config.get().setWebcamResolution(WebcamResolution.VGA);
}
}
public void mirrorCameraImage(ActionEvent event) {
@ -3150,7 +3154,7 @@ public class AppController implements Initializable {
@Subscribe
public void webcamResolutionChanged(WebcamResolutionChangedEvent event) {
useHdCameraResolutionProperty.set(event.isHdResolution());
useHdCameraResolutionProperty.set(event.getResolution().isWidescreenAspect());
}
@Subscribe

View file

@ -72,10 +72,6 @@ public class SparrowDesktop extends Application {
Config.get().setServerType(ServerType.ELECTRUM_SERVER);
}
if(Config.get().getHdCapture() == null && OsType.getCurrent() == OsType.MACOS) {
Config.get().setHdCapture(Boolean.TRUE);
}
System.setProperty(Wallet.ALLOW_DERIVATIONS_MATCHING_OTHER_SCRIPT_TYPES_PROPERTY, Boolean.toString(!Config.get().isValidateDerivationPaths()));
System.setProperty(Wallet.ALLOW_DERIVATIONS_MATCHING_OTHER_NETWORKS_PROPERTY, Boolean.toString(!Config.get().isValidateDerivationPaths()));

View file

@ -27,7 +27,6 @@ import com.sparrowwallet.hummingbird.registry.pathcomponent.PathComponent;
import com.sparrowwallet.sparrow.AppServices;
import com.sparrowwallet.sparrow.EventManager;
import com.sparrowwallet.sparrow.event.WebcamResolutionChangedEvent;
import com.sparrowwallet.sparrow.glyphfont.FontAwesome5;
import com.sparrowwallet.sparrow.io.Config;
import com.sparrowwallet.sparrow.io.bbqr.BBQRDecoder;
import com.sparrowwallet.sparrow.io.bbqr.BBQRException;
@ -39,13 +38,14 @@ import javafx.beans.property.SimpleDoubleProperty;
import javafx.beans.property.SimpleObjectProperty;
import javafx.beans.value.ChangeListener;
import javafx.beans.value.ObservableValue;
import javafx.collections.FXCollections;
import javafx.collections.ObservableList;
import javafx.geometry.Insets;
import javafx.scene.Node;
import javafx.scene.control.*;
import javafx.scene.layout.*;
import javafx.util.Duration;
import javafx.util.StringConverter;
import org.controlsfx.glyphfont.Glyph;
import org.controlsfx.tools.Borders;
import org.openpnp.capture.CaptureDevice;
import org.slf4j.Logger;
@ -77,19 +77,22 @@ public class QRScanDialog extends Dialog<QRScanDialog.Result> {
private static final Pattern PART_PATTERN = Pattern.compile("p(\\d+)of(\\d+) (.+)");
private static final int SCAN_PERIOD_MILLIS = 100;
private final ObjectProperty<WebcamResolution> webcamResolutionProperty = new SimpleObjectProperty<>(WebcamResolution.VGA);
private final ObjectProperty<CaptureDevice> webcamDeviceProperty = new SimpleObjectProperty<>();
private final ObjectProperty<WebcamResolution> webcamResolutionProperty = new SimpleObjectProperty<>(WebcamResolution.HD);
private final DoubleProperty percentComplete = new SimpleDoubleProperty(0.0);
private final ObjectProperty<CaptureDevice> webcamDeviceProperty = new SimpleObjectProperty<>();
private final ObservableList<CaptureDevice> foundDevices = FXCollections.observableList(new ArrayList<>());
private final ObservableList<WebcamResolution> availableResolutions = FXCollections.observableList(new ArrayList<>());
private boolean postOpenUpdate;
public QRScanDialog() {
this.urDecoder = new URDecoder();
this.legacyUrDecoder = new LegacyURDecoder();
this.bbqrDecoder = new BBQRDecoder();
if(Config.get().isHdCapture()) {
webcamResolutionProperty.set(WebcamResolution.HD);
if(Config.get().getWebcamResolution() != null) {
webcamResolutionProperty.set(Config.get().getWebcamResolution());
}
this.webcamService = new WebcamService(webcamResolutionProperty.get(), null);
@ -110,19 +113,35 @@ public class QRScanDialog extends Dialog<QRScanDialog.Result> {
progressBar.setPadding(new Insets(0, 10, 0, 10));
progressBar.setPrefWidth(Integer.MAX_VALUE);
progressBar.progressProperty().bind(percentComplete);
webcamService.openingProperty().addListener((_, _, newValue) -> {
webcamService.openingProperty().addListener((_, _, opening) -> {
if(percentComplete.get() <= 0.0) {
Platform.runLater(() -> percentComplete.set(newValue ? 0.0 : -1.0));
Platform.runLater(() -> percentComplete.set(opening ? 0.0 : -1.0));
}
if(opening) {
Platform.runLater(() -> {
try {
postOpenUpdate = true;
List<CaptureDevice> newDevices = new ArrayList<>(webcamService.getDevices());
newDevices.removeAll(foundDevices);
foundDevices.addAll(newDevices);
foundDevices.removeIf(device -> !webcamService.getDevices().contains(device));
if(Config.get().getWebcamDevice() != null && webcamDeviceProperty.get() == null) {
for(CaptureDevice device : webcamService.getFoundDevices()) {
for(CaptureDevice device : foundDevices) {
if(device.getName().equals(Config.get().getWebcamDevice())) {
webcamDeviceProperty.set(device);
}
}
}
updateList(availableResolutions, webcamService.getResolutions());
webcamResolutionProperty.set(webcamService.getResolution());
} finally {
postOpenUpdate = false;
}
});
}
});
webcamService.closedProperty().addListener((_, _, closed) -> {
if(closed && webcamResolutionProperty.get() != null) {
@ -148,12 +167,18 @@ public class QRScanDialog extends Dialog<QRScanDialog.Result> {
Platform.runLater(() -> setResult(new Result(exception)));
});
webcamService.start();
webcamResolutionProperty.addListener((_, _, newResolution) -> {
webcamResolutionProperty.addListener((_, oldResolution, newResolution) -> {
if(newResolution != null) {
setHeight(newResolution == WebcamResolution.HD ? (getHeight() - 100) : (getHeight() + 100));
EventManager.get().post(new WebcamResolutionChangedEvent(newResolution == WebcamResolution.HD));
if(newResolution.isStandardAspect() && oldResolution.isWidescreenAspect()) {
setHeight(getHeight() + 100);
} else if(newResolution.isWidescreenAspect() && oldResolution.isStandardAspect()) {
setHeight(getHeight() - 100);
}
EventManager.get().post(new WebcamResolutionChangedEvent(newResolution));
}
if(newResolution == null || !postOpenUpdate) {
webcamService.cancel();
}
});
webcamDeviceProperty.addListener((_, _, newValue) -> {
Config.get().setWebcamDevice(newValue.getName());
@ -163,9 +188,8 @@ public class QRScanDialog extends Dialog<QRScanDialog.Result> {
});
setOnCloseRequest(_ -> {
boolean isHdCapture = (webcamResolutionProperty.get() == WebcamResolution.HD);
if(Config.get().isHdCapture() != isHdCapture) {
Config.get().setHdCapture(isHdCapture);
if(webcamResolutionProperty.get() != null) {
Config.get().setWebcamResolution(webcamResolutionProperty.get());
}
Platform.runLater(() -> {
@ -175,11 +199,11 @@ public class QRScanDialog extends Dialog<QRScanDialog.Result> {
});
final ButtonType cancelButtonType = new javafx.scene.control.ButtonType("Close", ButtonBar.ButtonData.CANCEL_CLOSE);
final ButtonType hdButtonType = new javafx.scene.control.ButtonType("Use HD Capture", ButtonBar.ButtonData.LEFT);
final ButtonType camButtonType = new javafx.scene.control.ButtonType("Default Camera", ButtonBar.ButtonData.HELP_2);
dialogPane.getButtonTypes().addAll(hdButtonType, camButtonType, cancelButtonType);
final ButtonType deviceButtonType = new javafx.scene.control.ButtonType("Default Camera", ButtonBar.ButtonData.LEFT);
final ButtonType resolutionButtonType = new javafx.scene.control.ButtonType("Resolution", ButtonBar.ButtonData.HELP_2);
dialogPane.getButtonTypes().addAll(deviceButtonType, resolutionButtonType, cancelButtonType);
dialogPane.setPrefWidth(646);
dialogPane.setPrefHeight(webcamResolutionProperty.get() == WebcamResolution.HD ? 490 : 590);
dialogPane.setPrefHeight(webcamResolutionProperty.get().isWidescreenAspect() ? 490 : 590);
dialogPane.setMinHeight(dialogPane.getPrefHeight());
AppServices.moveToActiveWindowScreen(this);
@ -690,23 +714,9 @@ public class QRScanDialog extends Dialog<QRScanDialog.Result> {
private class QRScanDialogPane extends DialogPane {
@Override
protected Node createButton(ButtonType buttonType) {
Node button = null;
Node button;
if(buttonType.getButtonData() == ButtonBar.ButtonData.LEFT) {
ToggleButton hd = new ToggleButton(buttonType.getText());
hd.setSelected(webcamResolutionProperty.get() == WebcamResolution.HD);
hd.setGraphicTextGap(5);
setHdGraphic(hd, hd.isSelected());
final ButtonBar.ButtonData buttonData = buttonType.getButtonData();
ButtonBar.setButtonData(hd, buttonData);
hd.selectedProperty().addListener((observable, oldValue, newValue) -> {
webcamResolutionProperty.set(newValue ? WebcamResolution.HD : WebcamResolution.VGA);
setHdGraphic(hd, newValue);
});
button = hd;
} else if(buttonType.getButtonData() == ButtonBar.ButtonData.HELP_2) {
ComboBox<CaptureDevice> devicesCombo = new ComboBox<>(webcamService.getFoundDevices());
ComboBox<CaptureDevice> devicesCombo = new ComboBox<>(foundDevices);
devicesCombo.setConverter(new StringConverter<>() {
@Override
public String toString(CaptureDevice device) {
@ -719,9 +729,14 @@ public class QRScanDialog extends Dialog<QRScanDialog.Result> {
}
});
devicesCombo.valueProperty().bindBidirectional(webcamDeviceProperty);
ButtonBar.setButtonData(devicesCombo, ButtonBar.ButtonData.LEFT);
final ButtonBar.ButtonData buttonData = buttonType.getButtonData();
ButtonBar.setButtonData(devicesCombo, buttonData);
button = devicesCombo;
} else if(buttonType.getButtonData() == ButtonBar.ButtonData.HELP_2) {
ComboBox<WebcamResolution> resolutionsCombo = new ComboBox<>(availableResolutions);
resolutionsCombo.valueProperty().bindBidirectional(webcamResolutionProperty);
ButtonBar.setButtonData(resolutionsCombo, ButtonBar.ButtonData.LEFT);
button = resolutionsCombo;
} else {
button = super.createButton(buttonType);
}
@ -734,19 +749,39 @@ public class QRScanDialog extends Dialog<QRScanDialog.Result> {
button.disableProperty().bind(webcamService.openingProperty());
return button;
}
}
private void setHdGraphic(ToggleButton hd, boolean isHd) {
if(isHd) {
hd.setGraphic(getGlyph(FontAwesome5.Glyph.CHECK_CIRCLE));
public static <T extends Comparable<T>> void updateList(List<T> targetList, Collection<T> sourceList) {
List<T> sortedSource = new ArrayList<>(sourceList);
Collections.sort(sortedSource);
ListIterator<T> targetIter = targetList.listIterator();
int sourceIndex = 0;
while (sourceIndex < sortedSource.size() && targetIter.hasNext()) {
T sourceItem = sortedSource.get(sourceIndex);
T targetItem = targetIter.next();
int comparison = sourceItem.compareTo(targetItem);
if (comparison < 0) {
targetIter.previous(); // Back up to insert before
targetIter.add(sourceItem);
sourceIndex++;
} else if (comparison > 0) {
targetIter.remove();
} else {
hd.setGraphic(getGlyph(FontAwesome5.Glyph.BAN));
sourceIndex++;
}
}
private Glyph getGlyph(FontAwesome5.Glyph glyphName) {
Glyph glyph = new Glyph(FontAwesome5.FONT_NAME, glyphName);
glyph.setFontSize(11);
return glyph;
while (sourceIndex < sortedSource.size()) {
targetIter.add(sortedSource.get(sourceIndex));
sourceIndex++;
}
while (targetIter.hasNext()) {
targetIter.next();
targetIter.remove();
}
}

View file

@ -2,14 +2,20 @@ package com.sparrowwallet.sparrow.control;
import org.openpnp.capture.CaptureFormat;
public enum WebcamResolution {
VGA(640, 480),
HD(1280, 720);
import java.util.Arrays;
public enum WebcamResolution implements Comparable<WebcamResolution> {
VGA("480p", 640, 480),
HD("720p", 1280, 720),
FHD("1080p", 1920, 1080),
UHD4K("4K", 3840, 2160);
private final String name;
private final int width;
private final int height;
WebcamResolution(int width, int height) {
WebcamResolution(String name, int width, int height) {
this.name = name;
this.width = width;
this.height = height;
}
@ -18,6 +24,14 @@ public enum WebcamResolution {
return this.width * this.height;
}
public boolean isStandardAspect() {
return Arrays.equals(getAspectRatio(), new int[]{4, 3});
}
public boolean isWidescreenAspect() {
return Arrays.equals(getAspectRatio(), new int[]{16, 9});
}
public int[] getAspectRatio() {
int factor = this.getCommonFactor(this.width, this.height);
int wr = this.width / factor;
@ -29,6 +43,10 @@ public enum WebcamResolution {
return height == 0 ? width : this.getCommonFactor(height, width % height);
}
public String getName() {
return name;
}
public int getWidth() {
return this.width;
}
@ -38,8 +56,7 @@ public enum WebcamResolution {
}
public String toString() {
int[] ratio = this.getAspectRatio();
return super.toString() + ' ' + this.width + 'x' + this.height + " (" + ratio[0] + ':' + ratio[1] + ')';
return name;
}
public static WebcamResolution from(CaptureFormat captureFormat) {

View file

@ -10,8 +10,6 @@ import javafx.beans.property.BooleanProperty;
import javafx.beans.property.ObjectProperty;
import javafx.beans.property.SimpleBooleanProperty;
import javafx.beans.property.SimpleObjectProperty;
import javafx.collections.FXCollections;
import javafx.collections.ObservableList;
import javafx.concurrent.ScheduledService;
import javafx.concurrent.Task;
import javafx.embed.swing.SwingFXUtils;
@ -29,15 +27,18 @@ import java.awt.*;
import java.awt.geom.RoundRectangle2D;
import java.awt.image.BufferedImage;
import java.awt.image.WritableRaster;
import java.util.ArrayList;
import java.util.*;
import java.util.List;
import java.util.Map;
import java.util.function.Function;
import java.util.stream.Collectors;
import java.util.stream.Stream;
public class WebcamService extends ScheduledService<Image> {
private static final Logger log = LoggerFactory.getLogger(WebcamService.class);
private List<CaptureDevice> devices;
private Set<WebcamResolution> resolutions;
private WebcamResolution resolution;
private CaptureDevice device;
private final BooleanProperty opening = new SimpleBooleanProperty(false);
@ -50,7 +51,6 @@ public class WebcamService extends ScheduledService<Image> {
private final OpenPnpCapture capture;
private CaptureStream stream;
private long lastQrSampleTime;
private final ObservableList<CaptureDevice> foundDevices = FXCollections.observableList(new ArrayList<>());
private final Reader qrReader;
private final Bokmakierie bokmakierie;
@ -94,27 +94,22 @@ public class WebcamService extends ScheduledService<Image> {
protected Image call() throws Exception {
try {
if(stream == null) {
List<CaptureDevice> devices = capture.getDevices();
devices = capture.getDevices();
List<CaptureDevice> newDevices = new ArrayList<>(devices);
newDevices.removeAll(foundDevices);
foundDevices.addAll(newDevices);
foundDevices.removeIf(device -> !devices.contains(device));
if(foundDevices.isEmpty()) {
if(devices.isEmpty()) {
throw new UnsupportedOperationException("No cameras available");
}
CaptureDevice selectedDevice = foundDevices.getFirst();
CaptureDevice selectedDevice = devices.getFirst();
if(device != null) {
for(CaptureDevice webcam : foundDevices) {
for(CaptureDevice webcam : devices) {
if(webcam.getName().equals(device.getName())) {
selectedDevice = webcam;
}
}
} else if(Config.get().getWebcamDevice() != null) {
for(CaptureDevice webcam : foundDevices) {
for(CaptureDevice webcam : devices) {
if(webcam.getName().equals(Config.get().getWebcamDevice())) {
selectedDevice = webcam;
}
@ -129,17 +124,22 @@ public class WebcamService extends ScheduledService<Image> {
Map<WebcamResolution, CaptureFormat> supportedResolutions = device.getFormats().stream()
.filter(f -> WebcamResolution.from(f) != null)
.collect(Collectors.toMap(WebcamResolution::from, Function.identity(), (u, v) -> u));
.collect(Collectors.toMap(WebcamResolution::from, Function.identity(), (u, v) -> u, TreeMap::new));
resolutions = supportedResolutions.keySet();
CaptureFormat format = supportedResolutions.get(resolution);
if(format == null) {
if(!supportedResolutions.isEmpty()) {
format = supportedResolutions.values().iterator().next();
resolution = getNearestEnum(resolution, supportedResolutions.keySet().toArray(new WebcamResolution[0]));
format = supportedResolutions.get(resolution);
} else {
format = device.getFormats().getFirst();
log.warn("Could not get standard capture resolution, using " + format.getFormatInfo().width + "x" + format.getFormatInfo().height);
}
}
log.warn("Could not get requested capture resolution, using " + format.getFormatInfo().width + "x" + format.getFormatInfo().height);
if(log.isDebugEnabled()) {
log.debug("Opening capture stream with format " + format.getFormatInfo().width + "x" + format.getFormatInfo().height + " (" + fourCCToString(format.getFormatInfo().fourcc) + ")");
}
opening.set(true);
@ -237,7 +237,7 @@ public class WebcamService extends ScheduledService<Image> {
g2d.drawImage(image, 0, 0, null);
float[] dash1 = {10.0f};
g2d.setColor(Color.BLACK);
g2d.setStroke(new BasicStroke(resolution == WebcamResolution.HD ? 3.0f : 1.5f, BasicStroke.CAP_BUTT, BasicStroke.JOIN_MITER, 10.0f, dash1, 0.0f));
g2d.setStroke(new BasicStroke(resolution.isWidescreenAspect() ? 3.0f : 1.5f, BasicStroke.CAP_BUTT, BasicStroke.JOIN_MITER, 10.0f, dash1, 0.0f));
g2d.draw(new RoundRectangle2D.Double(cropped.x, cropped.y, cropped.length, cropped.length, 10, 10));
g2d.dispose();
return clone;
@ -274,6 +274,14 @@ public class WebcamService extends ScheduledService<Image> {
}
}
public List<CaptureDevice> getDevices() {
return devices;
}
public Set<WebcamResolution> getResolutions() {
return resolutions;
}
public Result getResult() {
return resultProperty.get();
}
@ -290,6 +298,10 @@ public class WebcamService extends ScheduledService<Image> {
return resolution.getHeight();
}
public WebcamResolution getResolution() {
return resolution;
}
public void setResolution(WebcamResolution resolution) {
this.resolution = resolution;
}
@ -302,10 +314,6 @@ public class WebcamService extends ScheduledService<Image> {
this.device = device;
}
public ObservableList<CaptureDevice> getFoundDevices() {
return foundDevices;
}
public BooleanProperty openingProperty() {
return opening;
}
@ -323,6 +331,17 @@ public class WebcamService extends ScheduledService<Image> {
});
}
public static <T extends Enum<T>> T getNearestEnum(T target) {
return getNearestEnum(target, target.getDeclaringClass().getEnumConstants());
}
public static <T extends Enum<T>> T getNearestEnum(T target, T[] values) {
int ordinal = target.ordinal();
return Stream.concat(ordinal > 0 ? Stream.of(values[ordinal - 1]) : Stream.empty(), ordinal < values.length - 1 ? Stream.of(values[ordinal + 1]) : Stream.empty())
.findFirst()
.orElse(null);
}
private static class CroppedDimension {
public int x;
public int y;

View file

@ -1,13 +1,15 @@
package com.sparrowwallet.sparrow.event;
public class WebcamResolutionChangedEvent {
private final boolean hdResolution;
import com.sparrowwallet.sparrow.control.WebcamResolution;
public WebcamResolutionChangedEvent(boolean hdResolution) {
this.hdResolution = hdResolution;
public class WebcamResolutionChangedEvent {
private final WebcamResolution resolution;
public WebcamResolutionChangedEvent(WebcamResolution resolution) {
this.resolution = resolution;
}
public boolean isHdResolution() {
return hdResolution;
public WebcamResolution getResolution() {
return resolution;
}
}

View file

@ -6,6 +6,7 @@ import com.sparrowwallet.sparrow.UnitFormat;
import com.sparrowwallet.sparrow.Mode;
import com.sparrowwallet.sparrow.Theme;
import com.sparrowwallet.sparrow.control.QRDensity;
import com.sparrowwallet.sparrow.control.WebcamResolution;
import com.sparrowwallet.sparrow.net.*;
import com.sparrowwallet.sparrow.wallet.FeeRatesSelection;
import com.sparrowwallet.sparrow.wallet.OptimizationStrategy;
@ -56,7 +57,7 @@ public class Config {
private long dustAttackThreshold = DUST_ATTACK_THRESHOLD_SATS;
private int enumerateHwPeriod = ENUMERATE_HW_PERIOD_SECS;
private QRDensity qrDensity;
private Boolean hdCapture;
private WebcamResolution webcamResolution;
private boolean mirrorCapture = true;
private boolean useZbar = true;
private String webcamDevice;
@ -383,16 +384,12 @@ public class Config {
flush();
}
public Boolean getHdCapture() {
return hdCapture;
public WebcamResolution getWebcamResolution() {
return webcamResolution;
}
public Boolean isHdCapture() {
return hdCapture != null && hdCapture;
}
public void setHdCapture(Boolean hdCapture) {
this.hdCapture = hdCapture;
public void setWebcamResolution(WebcamResolution webcamResolution) {
this.webcamResolution = webcamResolution;
flush();
}