diff --git a/pano/README.md b/pano/README.md index 3485af31..edb34f77 100644 --- a/pano/README.md +++ b/pano/README.md @@ -148,8 +148,11 @@ Note that some of the individual steps within each panorama stitch (e.g., `enble ### View the tour -TODO +In the host OS, point a web browser at `http://127.0.0.1:8080/`. The `run.sh` script forwards port 8080 of the host to port 80 of the container, where an Apache server is running to provide a preview of the tour. +### Install the tour + +Recursively copy the `$ISAAC_PANO_OUTPUT/html` folder wherever you want on your web server. Make sure permissions are set to enable your users to access files in the folder. No special configuration should be needed as long as the server is configured to serve static files with the appropriate MIME types. None of the application logic runs on the web server; the viewing interfaces run completely in the browser. When users make annotations, they will be stored locally in the user's web browser, not on the web server. ### Generate target pose from image @@ -185,4 +188,4 @@ An example output would be: Things to look for are that the timestamps are not too large, this would mean that results are unreliable. Pay attention to the point cloud point to vector distance to make sure that the point is not too far away from the target vector, meaning that the point cloud does not include the target which is possible due to different field of views between the cameras. -Lastly the mesh and pcl should not disagree, if they do, some manual analysis is needed. Be aware that the attitude provided is defined by the direction pointing at the target assuming roll zero. \ No newline at end of file +Lastly the mesh and pcl should not disagree, if they do, some manual analysis is needed. Be aware that the attitude provided is defined by the direction pointing at the target assuming roll zero. diff --git a/pano/docker/pano.Dockerfile b/pano/docker/pano.Dockerfile index ea986cd4..80017faa 100644 --- a/pano/docker/pano.Dockerfile +++ b/pano/docker/pano.Dockerfile @@ -4,29 +4,37 @@ ARG REMOTE=ghcr.io/nasa FROM ${REMOTE}/isaac:latest-ubuntu${UBUNTU_VERSION} +# apache2: Local web server for previewing pano tour interface # default-jre: Java runtime needed for minifying Pannellum web files # hugin: pano stitching tools (and hsi Python interface) # libvips-tools: convert images to multires, zoomable in OpenSeaDragon # python3-pip: for installing Python packages later in this Dockerfile RUN apt-get update \ && apt-get install -y --no-install-recommends \ + apache2 \ default-jre \ + gfortran \ hugin \ + libfftw3-dev \ + libopenblas-dev \ libvips-tools \ python3-pip \ - gfortran libopenblas-dev libfftw3-dev \ && rm -rf /var/lib/apt/lists/* -# pandas: pulled in as pyshtools dependency but install breaks if not mentioned explicitly (?) -# pyshtools: used during Pannellum multires generation -# snakemake: modern build system based on Python, manages stitching workflows - # Install Jupyter explicitly first -RUN pip3 install --no-cache-dir --upgrade pip && \ - pip3 install --no-cache-dir jupyter +RUN pip3 install --no-cache-dir --upgrade pip \ + && pip3 install --no-cache-dir jupyter # Install other Python packages: jupyter package needs to be installed before attempting to build pyshtools -RUN pip3 install --no-cache-dir pandas pyshtools snakemake pulp==2.7 --ignore-installed PyYAML +# - pandas: pulled in as pyshtools dependency but install breaks if not mentioned explicitly (?) +# - pyshtools: used during Pannellum multires generation +# - snakemake: modern build system based on Python, manages stitching workflows +RUN pip3 install --no-cache-dir \ + --ignore-installed PyYAML \ + pandas \ + pulp==2.7 \ + pyshtools \ + snakemake # pannellum: library for viewing/navigating panorama tours RUN mkdir -p /opt \ @@ -73,3 +81,16 @@ RUN echo 'source "/src/isaac/devel/setup.bash"\nexport ASTROBEE_CONFIG_DIR="/src # once new pano folder is merged into develop and official docker # images are updated. RUN echo 'export ROS_PACKAGE_PATH="${ROS_PACKAGE_PATH}:/src/isaac/src/pano/pano_stitch::/src/isaac/src/pano/pano_view"' >> "${HOME}/.bashrc" + +# This is a bit unusual, but starting apache in .bashrc ensures it's +# running whenever we run an interactive session using +# run.sh. (Running it multiple times doesn't cause a problem.) +RUN echo 'apachectl start' >> "${HOME}/.bashrc" + +# Make /output/html the DocumentRoot for the apache2 debug web server +RUN perl -i -ple 's:/var/www:/output:g' \ + /etc/apache2/apache2.conf \ + /etc/apache2/sites-available/000-default.conf + +# Expose local web server for pano tour preview +EXPOSE 80 diff --git a/pano/docker/run.sh b/pano/docker/run.sh index f6513fb2..2879e59d 100755 --- a/pano/docker/run.sh +++ b/pano/docker/run.sh @@ -20,4 +20,5 @@ docker run \ --mount type=bind,source=${ISAAC_PANO_INPUT},target=/input,readonly \ --mount type=bind,source=${ISAAC_PANO_OUTPUT},target=/output \ --mount type=bind,source=${ISAAC_SRC},target=/src/isaac/src \ + -p 127.0.0.1:8080:80 \ isaac/pano diff --git a/pano/pano_stitch/scripts/pano_image_meta.py b/pano/pano_stitch/scripts/pano_image_meta.py index b20934cd..c8c1f6f3 100755 --- a/pano/pano_stitch/scripts/pano_image_meta.py +++ b/pano/pano_stitch/scripts/pano_image_meta.py @@ -26,7 +26,7 @@ import rosbag from tf.transformations import euler_from_quaternion -IMAGE_TOPIC = ["/hw/cam_sci/compressed", "/hw/cam_sci_info"] +IMAGE_TOPICS = ["/hw/cam_sci/compressed", "/hw/cam_sci_info"] POSE_TOPIC = "/loc/pose" FIELD_NAMES = ( "timestamp", @@ -50,9 +50,8 @@ def get_image_meta(inbag_path, num_images=None): images = [] with rosbag.Bag(inbag_path) as bag: img_meta = None - topics = IMAGE_TOPIC + [POSE_TOPIC] - for topic, msg, t in bag.read_messages(topics): - if topic in IMAGE_TOPIC: + for topic, msg, t in bag.read_messages([*IMAGE_TOPICS, POSE_TOPIC]): + if topic in IMAGE_TOPICS: if num_images is not None and len(images) == num_images: break diff --git a/pano/pano_view/CMakeLists.txt b/pano/pano_view/CMakeLists.txt index 0c727839..d0251ffd 100644 --- a/pano/pano_view/CMakeLists.txt +++ b/pano/pano_view/CMakeLists.txt @@ -82,7 +82,7 @@ add_dependencies(point_cloud_intersect ${catkin_EXPORTED_TARGETS}) target_link_libraries(point_cloud_intersect ${catkin_LIBRARIES}) -## Declare a C++ executable: find_point_coordinate +## Declare a C++ executable: find_point_coordinate add_executable(find_point_coordinate tools/find_point_coordinate.cc) add_dependencies(find_point_coordinate ${catkin_EXPORTED_TARGETS}) target_link_libraries(find_point_coordinate mesh_intersect point_cloud_intersect diff --git a/pano/pano_view/config/phase1x_inspection_results.config b/pano/pano_view/config/phase1x_inspection_results.config new file mode 100644 index 00000000..2e9d3bc6 --- /dev/null +++ b/pano/pano_view/config/phase1x_inspection_results.config @@ -0,0 +1,183 @@ +# ====================================================================== +# The content below the line is from the file +# isaac/src/pano/pano_view/config/phase1x_inspection_results.config +# and is designed to be copy/pasted at the bottom of pano_meta.yaml. + +# How inspection_results was generated: The photos were manually +# selected from all hatch seal targeted inspection photos to be the +# best ones (at most one from each focus stack) for showing +# recognizable damage sites. Pitch/yaw values for each image were +# manually selected using the Pannellum hotSpotDebug feature. +inspection_results: + scene001_isaac11_bumble_usl_bay6: + # ISAAC11 USL aft hatch seal inspection + - pitch: -19.496113033067832 + yaw: -178.49501734467697 + image: /input/inspection_results/isaac11_usl_aft/1657549131.173.jpg + slug: Hatch Seal ISAAC16 USL AFT + + scene004_isaac11_queen_usl_bay1: + # ISAAC16 USL forward hatch seal deck section positions, numbered 0 .. 3 port -> starboard + # Only position 1 has a recognizable damage site, leaving out the others + + #- pitch: -23.829139007892287 + # yaw: -9.57794857674467 + # image: /input/inspection_results/isaac16_usl_fwd/1720198615.236.jpg + # slug: Hatch Seal ISAAC16 USL FWD 0 + - pitch: -24.233722398730837 + yaw: -2.3869610431019694 + image: /input/inspection_results/isaac16_usl_fwd/1720199288.313.jpg + slug: Hatch Seal ISAAC16 USL FWD 1 + #- pitch: -24.210064732216285 + # yaw: 6.143908859653246 + # image: /input/inspection_results/isaac16_usl_fwd/1720199367.399.jpg + # slug: Hatch Seal ISAAC16 USL FWD 2 + #- pitch: -24.167778451658435 + # yaw: 10.29111636247168 + # image: /input/inspection_results/isaac16_usl_fwd/1720199447.824.jpg + # slug: Hatch Seal ISAAC16 USL FWD 3 + + # ISAAC11 USL forward hatch seal inspection + - pitch: -24.233722398730837 + yaw: -1 + image: /input/inspection_results/isaac11_usl_fwd/1657551186.506.jpg + slug: Hatch Seal ISAAC11 USL FWD + +# How initial_annotations was generated: I used the existing target +# selection feature to annotate the (somewhat) recognizable damage +# sites, saved the results, and simply copy/pasted the file contents +# here. Conveniently, YAML is a superset of the JSON format used for +# the annotations, so it was already valid YAML (even if the style +# clashes with the one used above). +initial_annotations: { + "scene004_isaac11_queen_usl_bay1": { + "1720199288.313": [ + { + "@context": "http://www.w3.org/ns/anno.jsonld", + "type": "Annotation", + "body": [ + { + "type": "TextualBody", + "value": "A: Divot on Inner Seal", + "purpose": "commenting" + } + ], + "target": { + "source": "http://localhost:8080/src/undefined", + "selector": { + "type": "FragmentSelector", + "conformsTo": "http://www.w3.org/TR/media-frags/", + "value": "xywh=pixel:3449.820556640625,1809.1005859375,52.701171875,39.3167724609375" + } + }, + "id": "#5e67f4b4-86ed-4eaf-9bf2-caeac4358691" + }, + { + "@context": "http://www.w3.org/ns/anno.jsonld", + "type": "Annotation", + "body": [ + { + "type": "TextualBody", + "value": "B: Cut on Inner Seal", + "purpose": "commenting" + } + ], + "target": { + "source": "http://localhost:8080/src/undefined", + "selector": { + "type": "FragmentSelector", + "conformsTo": "http://www.w3.org/TR/media-frags/", + "value": "xywh=pixel:3723.783203125,1791.1153564453125,124.642333984375,58.97509765625" + } + }, + "id": "#f5f60596-a44a-48d2-aafb-49d63621bd20" + } + ], + "1657551186.506": [ + { + "@context": "http://www.w3.org/ns/anno.jsonld", + "type": "Annotation", + "body": [ + { + "type": "TextualBody", + "value": "B: Cut on Inner Seal", + "purpose": "commenting" + } + ], + "target": { + "source": "http://localhost:8080/src/undefined", + "selector": { + "type": "FragmentSelector", + "conformsTo": "http://www.w3.org/TR/media-frags/", + "value": "xywh=pixel:3658.030517578125,2492.563720703125,110.6806640625,51.651123046875" + } + }, + "id": "#5a8d060a-259e-47f9-9b42-519a09246897" + }, + { + "@context": "http://www.w3.org/ns/anno.jsonld", + "type": "Annotation", + "body": [ + { + "type": "TextualBody", + "value": "A: Divot on Inner Seal", + "purpose": "commenting" + } + ], + "target": { + "source": "http://localhost:8080/src/undefined", + "selector": { + "type": "FragmentSelector", + "conformsTo": "http://www.w3.org/TR/media-frags/", + "value": "xywh=pixel:3269.641845703125,2496.58837890625,52.992431640625,42.930908203125" + } + }, + "id": "#49a5daa0-41c1-4856-b21b-09164687415a" + } + ] + }, + "scene001_isaac11_bumble_usl_bay6": { + "1657549131.173": [ + { + "@context": "http://www.w3.org/ns/anno.jsonld", + "type": "Annotation", + "body": [ + { + "type": "TextualBody", + "value": "F: Divot and Cut on Inner Seal", + "purpose": "commenting" + } + ], + "target": { + "source": "http://localhost:8080/src/undefined", + "selector": { + "type": "FragmentSelector", + "conformsTo": "http://www.w3.org/TR/media-frags/", + "value": "xywh=pixel:2259.4794921875,2235.703125,56.346435546875,41.589111328125" + } + }, + "id": "#0b8ab636-3697-425a-b683-089712796e24" + }, + { + "@context": "http://www.w3.org/ns/anno.jsonld", + "type": "Annotation", + "body": [ + { + "type": "TextualBody", + "value": "G: Divot on Inner Seal", + "purpose": "commenting" + } + ], + "target": { + "source": "http://localhost:8080/src/undefined", + "selector": { + "type": "FragmentSelector", + "conformsTo": "http://www.w3.org/TR/media-frags/", + "value": "xywh=pixel:2729.704833984375,2227.653564453125,63.72509765625,38.906005859375" + } + }, + "id": "#84eb0fa7-4601-43e1-a900-46077fc93c4a" + } + ] + } +} diff --git a/pano/pano_view/media/camera_highlight.png b/pano/pano_view/media/camera_highlight.png new file mode 100644 index 00000000..6c742bd0 Binary files /dev/null and b/pano/pano_view/media/camera_highlight.png differ diff --git a/pano/pano_view/media/favicon/favicon-128x128.png b/pano/pano_view/media/favicon/favicon-128x128.png new file mode 100755 index 00000000..5fcc91ba Binary files /dev/null and b/pano/pano_view/media/favicon/favicon-128x128.png differ diff --git a/pano/pano_view/media/favicon/favicon-16x16.png b/pano/pano_view/media/favicon/favicon-16x16.png new file mode 100755 index 00000000..3a8fd9d4 Binary files /dev/null and b/pano/pano_view/media/favicon/favicon-16x16.png differ diff --git a/pano/pano_view/media/favicon/favicon-192x192.png b/pano/pano_view/media/favicon/favicon-192x192.png new file mode 100755 index 00000000..39a0e8e8 Binary files /dev/null and b/pano/pano_view/media/favicon/favicon-192x192.png differ diff --git a/pano/pano_view/media/favicon/favicon-196x196.png b/pano/pano_view/media/favicon/favicon-196x196.png new file mode 100755 index 00000000..4d79ea2a Binary files /dev/null and b/pano/pano_view/media/favicon/favicon-196x196.png differ diff --git a/pano/pano_view/media/favicon/favicon-228x228.png b/pano/pano_view/media/favicon/favicon-228x228.png new file mode 100755 index 00000000..58a5ea38 Binary files /dev/null and b/pano/pano_view/media/favicon/favicon-228x228.png differ diff --git a/pano/pano_view/media/favicon/favicon-32x32.png b/pano/pano_view/media/favicon/favicon-32x32.png new file mode 100755 index 00000000..f0a1d1aa Binary files /dev/null and b/pano/pano_view/media/favicon/favicon-32x32.png differ diff --git a/pano/pano_view/media/favicon/favicon-57x57.png b/pano/pano_view/media/favicon/favicon-57x57.png new file mode 100755 index 00000000..4ef8e0b3 Binary files /dev/null and b/pano/pano_view/media/favicon/favicon-57x57.png differ diff --git a/pano/pano_view/media/favicon/favicon-76x76.png b/pano/pano_view/media/favicon/favicon-76x76.png new file mode 100755 index 00000000..0e4502b2 Binary files /dev/null and b/pano/pano_view/media/favicon/favicon-76x76.png differ diff --git a/pano/pano_view/media/favicon/favicon-96x96.png b/pano/pano_view/media/favicon/favicon-96x96.png new file mode 100755 index 00000000..c6c60bc4 Binary files /dev/null and b/pano/pano_view/media/favicon/favicon-96x96.png differ diff --git a/pano/pano_view/media/inspection.png b/pano/pano_view/media/inspection.png new file mode 100644 index 00000000..0697f755 Binary files /dev/null and b/pano/pano_view/media/inspection.png differ diff --git a/pano/pano_view/scripts/generate_tour.py b/pano/pano_view/scripts/generate_tour.py index a0d7b836..82ad7613 100755 --- a/pano/pano_view/scripts/generate_tour.py +++ b/pano/pano_view/scripts/generate_tour.py @@ -178,7 +178,8 @@ def install_static_files(out_folder, package_paths): # we can just install them; in the future we might replace them # with templates to provide greater flexibility, which would require # template rendering. - install_glob(os.path.join(PANO_VIEW_ROOT, "templates/pannellum.htm"), out_folder) + install_glob(os.path.join(PANO_VIEW_ROOT, "templates/pannellum.html"), out_folder) + install_glob(os.path.join(PANO_VIEW_ROOT, "templates/help.html"), out_folder) install_file( os.path.join(PANO_VIEW_ROOT, "templates/isaac_source_image.html"), os.path.join(out_folder, "src"), @@ -336,18 +337,35 @@ def link_source_images(config, tour_scenes, out_folder): with open(src_images_meta_path, "r") as src_images_meta_stream: src_images_meta = json.load(src_images_meta_stream) + scene_meta = get_display_scene_meta(scene_id, config_scene_meta) + img_ids = sorted(src_images_meta.keys()) for i, img_id in enumerate(img_ids): + img_num = i + 1 img_meta = src_images_meta[img_id] + try: + # get manually configured slug for inspection result + text = img_meta["slug"] + slug = text.replace(" ", "_") + except KeyError: + # generic fallback for pano images + text = "Image %d" % img_num + slug = "%s_%s_Image_%s" % ( + scene_meta["module"], + scene_meta["bay"], + img_num, + ) hot_spots.append( { "type": "info", - "id": i, - "text": "Image %d" % i, + "id": img_id, + "text": text, "yaw": img_meta["yaw"] - tour_scene.get("northOffset", 0), "pitch": img_meta["pitch"], - "URL": "src/#scene=%s&imageId=%s" % (scene_id, img_id), - "cssClass": "isaac-source-image pnlm-hotspot pnlm-sprite", + "task": img_meta["task"], + "URL": "src/#scene=%s&imageId=%s&slug=%s" + % (scene_id, img_id, slug), + "cssClass": f"isaac-source-image isaac-{img_meta['task']} pnlm-hotspot pnlm-sprite", "attributes": { "target": "_blank", }, @@ -362,6 +380,7 @@ def generate_tour_json(config, out_folder): tour_scenes = {} tour["scenes"] = tour_scenes + tour["initialAnnotations"] = config["initial_annotations"] for scene_id, config_scene_meta in config["scenes"].items(): # Read tiler scene metadata @@ -430,7 +449,7 @@ def generate_scene_index(config, out_folder): index.append( fill_field( ( - '
  • ' + '
  • ' "{module} {bay}" "
  • " ), @@ -452,14 +471,37 @@ def generate_scene_index(config, out_folder): print("wrote to %s" % out_path) +def install_pano_images(config, out_folder): + for scene_id, config_scene_meta in config["scenes"].items(): + # Would be more in the spirit of things to not hard-code this input path + in_image = os.path.join("/output/stitch", scene_id, "pano.jpg") + out_image_folder = os.path.join(out_folder, "scenes", scene_id) + install_file(in_image, out_image_folder, "pano.jpg") + + +def reorganize_config(config): + """ + Modify `config` in place. For the top-level inspection_results + field: add the value for each scene into the config field of the + same name for that scene. (And delete the top-level field.) + """ + scenes = config["scenes"] + for field in ["inspection_results"]: + value = config.pop(field, {}) + for scene_id, scene_value in value.items(): + scenes[scene_id][field] = scene_value + + def generate_tour(config_path, out_folder, package_paths): with open(config_path, "r") as config_stream: config = yaml.safe_load(config_stream) + reorganize_config(config) install_static_files(out_folder, package_paths) generate_tour_json(config, out_folder) generate_scene_index(config, out_folder) - dosys("chmod a+rX %s" % out_folder) + install_pano_images(config, out_folder) + dosys("chmod -R a+rX %s" % out_folder) class CustomFormatter( diff --git a/pano/pano_view/scripts/prep_source_images.py b/pano/pano_view/scripts/prep_source_images.py index d617773a..2ec0b2be 100755 --- a/pano/pano_view/scripts/prep_source_images.py +++ b/pano/pano_view/scripts/prep_source_images.py @@ -31,6 +31,8 @@ import hsi import yaml +OUTPUT_PNG = False + def dosys(cmd, exit_on_error=True): print("+ " + cmd) @@ -50,7 +52,7 @@ def read_pto(pto_path): return pano -def read_scene_source_images_meta(stitch_folder, scene_id): +def read_scene_source_images_meta(stitch_folder, scene_id, images_dir): pto_path = os.path.join(stitch_folder, scene_id, "stitch_final.pto") pano = read_pto(pto_path) num_images = pano.getNrOfImages() @@ -61,6 +63,8 @@ def read_scene_source_images_meta(stitch_folder, scene_id): images_meta[img_id] = { "yaw": img.getYaw(), "pitch": img.getPitch(), + "image": os.path.join(images_dir, img_id + ".jpg"), + "task": "pano", } return images_meta @@ -80,10 +84,15 @@ def do_prep_image(job_args): if any((os.path.exists(p) for p in partial_paths)): dosys("rm -rf %s" % (" ".join(partial_paths))) - dosys("vips dzsave %s %s_partial" % (image_in, dz_out)) + png_arg = "--suffix .png" if OUTPUT_PNG else "" + dosys("vips dzsave %s %s_partial %s" % (image_in, dz_out, png_arg)) for p in partial_paths: dosys("mv %s %s" % (p, p.replace("_partial", ""))) + # Copy original source image to output as well (enables download link) + ext = os.path.splitext(image_in)[1] + dosys("cp %s %s" % (image_in, dz_out + ext)) + def write_images_meta(images_meta, meta_out_path): meta_out_path_parent = os.path.dirname(meta_out_path) @@ -95,14 +104,26 @@ def write_images_meta(images_meta, meta_out_path): print("wrote %s" % meta_out_path) +def get_inspection_results(scene_meta): + infos = scene_meta.get("inspection_results", []) + for info in infos: + info["task"] = "inspection" + return { + os.path.splitext(os.path.basename(info["image"]))[0]: info for info in infos + } + + def get_scene_q(config, stitch_folder, out_folder, scene_id): - images_meta = read_scene_source_images_meta(stitch_folder, scene_id) scene_meta = config["scenes"][scene_id] + images_meta = read_scene_source_images_meta( + stitch_folder, scene_id, scene_meta["images_dir"] + ) + images_meta.update(get_inspection_results(scene_meta)) scene_out = os.path.join(out_folder, "source_images", scene_id) prep_image_q = [] - for img_id in images_meta.keys(): - image_in = os.path.join(scene_meta["images_dir"], img_id + ".jpg") + for img_id, img_meta in images_meta.items(): + image_in = img_meta["image"] dz_out = os.path.join(scene_out, img_id) if os.path.exists(dz_out + ".dzi"): continue @@ -129,9 +150,23 @@ def join_lists(lists): return functools.reduce(operator.iadd, lists, []) +def reorganize_config(config): + """ + Modify `config` in place. For the top-level inspection_results + field: add the value for each scene into the config field of the + same name for that scene. (And delete the top-level field.) + """ + scenes = config["scenes"] + for field in ["inspection_results"]: + value = config.pop(field, {}) + for scene_id, scene_value in value.items(): + scenes[scene_id][field] = scene_value + + def prep_source_images(config_path, stitch_folder, out_folder, num_jobs): with open(config_path, "r") as config_stream: config = yaml.safe_load(config_stream) + reorganize_config(config) prep_image_q = join_lists( ( diff --git a/pano/pano_view/static/css/isaac_pano.css b/pano/pano_view/static/css/isaac_pano.css index dfa5a280..9042933b 100644 --- a/pano/pano_view/static/css/isaac_pano.css +++ b/pano/pano_view/static/css/isaac_pano.css @@ -23,6 +23,23 @@ body { font-family: sans-serif; } +.isaac-flex { + display: flex; + flex-flow: column; + height: 100%; +} + +.pnlm-container { + flex: 1 1 auto; +} + +.isaac-title { + font-size: 1.2em; + font-weight: bold; + vertical-align: middle; + margin-right: 0.5em; +} + /********************************************************************** * Overview map **********************************************************************/ @@ -80,9 +97,17 @@ body { background-position: 0 0px !important; } +.isaac-source-image.isaac-annotated { + background-image: url('../media/camera_highlight.png') !important; +} + +.isaac-source-image.isaac-inspection { + background-image: url('../media/inspection.png') !important; +} + /* make this wider so scene link tooltips don't have to wrap */ div.pnlm-tooltip span { - max-width: 300px; + max-width: 400px; } /********************************************************************** @@ -90,6 +115,7 @@ div.pnlm-tooltip span { **********************************************************************/ .isaac-navbar { + flex: 0 1; padding: 5px; } @@ -124,7 +150,7 @@ div.pnlm-tooltip span { } .isaac-dropdown-content .isaac-toggle-entry { - font-size: 12px; + font-size: 16px; color: black; padding: 3px 4px; text-decoration: none; @@ -146,3 +172,7 @@ div.pnlm-tooltip span { .isaac-dropdown .show { display: block; } + +#isaac-load-input { + display: none; +} diff --git a/pano/pano_view/static/js/isaac_pano.js b/pano/pano_view/static/js/isaac_pano.js index bca475a8..d83d8e4f 100644 --- a/pano/pano_view/static/js/isaac_pano.js +++ b/pano/pano_view/static/js/isaac_pano.js @@ -39,10 +39,9 @@ function getClosestScene(pos, config, maxDistance) { } function getMousePos(event) { - var mapOriginX = window.overviewMap.offsetLeft; - var mapOriginY = window.overviewMap.offsetTop; - var offsetX = event.clientX - mapOriginX; - var offsetY = event.clientY - mapOriginY; + var mapRect = window.overviewMap.getBoundingClientRect(); + var offsetX = event.clientX - mapRect.x; + var offsetY = event.clientY - mapRect.y; return [offsetX, offsetY]; } @@ -81,7 +80,7 @@ function updateMapCurrent() { } function updateYaw() { - var yaw = window.viewer.getConfig().yaw; + var yaw = window.viewer.getYaw(); var mapCurrent = window.mapCurrent; var northOffset = window.viewer.getConfig().northOffset || 0; mapCurrent.style.transform = ( @@ -90,49 +89,6 @@ function updateYaw() { ); } -function initIsaacPano(event) { - var config = event.configFromURL; - window.initialConfig = config; - - var uiContainer = document.getElementsByClassName('pnlm-ui')[0]; - - var overviewMap = document.createElement('div'); - overviewMap.className = 'pnlm-overview-map pnlm-controls pnlm-control'; - uiContainer.appendChild(overviewMap); - window.overviewMap = overviewMap; - - overviewMap.addEventListener('click', overviewMapClick); - overviewMap.addEventListener('mousemove', overviewMapMouseMove); - overviewMap.addEventListener('mouseleave', overviewMapMouseLeave); - - for (let [sceneName, scene] of Object.entries(config.scenes)) { - var mapMarker = document.createElement('div'); - mapMarker.className = 'pnlm-map-marker'; - var pos = scene.overviewMapPosition; - mapMarker.style.left = pos[0] + "px"; - mapMarker.style.top = pos[1] + "px"; - overviewMap.appendChild(mapMarker); - } - - var mapHighlight = document.createElement('div'); - mapHighlight.className = 'pnlm-map-highlight'; - overviewMap.appendChild(mapHighlight); - window.mapHighlight = mapHighlight; - - var mapCurrent = document.createElement('div'); - mapCurrent.className = 'pnlm-map-current'; - overviewMap.appendChild(mapCurrent); - window.mapCurrent = mapCurrent; - - updateMapCurrent(); - - window.viewer.on("scenechange", updateMapCurrent); - window.viewer.on("mouseup", updateYaw); - window.viewer.on("touchend", updateYaw); -} - -document.addEventListener('pannellumloaded', initIsaacPano, false); - /********************************************************************** * View options dropdown menu **********************************************************************/ @@ -156,51 +112,92 @@ function isaacSetVisibility(className, visibility) { function isaacShowNavControls(visibility) { isaacSetVisibility('pnlm-controls-container', visibility); + isaacSetVisibility('pnlm-panorama-info', visibility); } function isaacShowOverviewMap(visibility) { isaacSetVisibility('pnlm-overview-map', visibility); } -function isaacShowHotSpotType(hotSpotType, visibility) { +/* Return true if `hotSpot` in `sceneId` has annotations according to + * `storageItem`. + * + * @param storageItem The JSON-parsed HTML5 local storage object containing annotations. + * @param sceneId The sceneId for the current pano scene from the tour config. + * @param hotSpot A source image hotspot object from the tour config. + */ +function isaacHasAnnotations(storageItem, sceneId, hotSpot) { + const sceneAnnotations = storageItem[sceneId]; + if (sceneAnnotations == undefined) return false; + + const imageAnnotations = sceneAnnotations[hotSpot.id]; + if (imageAnnotations == undefined) return false; + + return imageAnnotations.length > 0; +} + +/* Set the cssClass property of `hotSpot` so that it is rendered in the + * annotated style or not depending on the `isAnnotated` flag. + * + * @param hotSpot A source image hotspot object from the tour config. + * @param isAnnotated If true, style the hotspot in the annotated style. + */ +function isaacSetAnnotatedStyle(hotSpot, isAnnotated) { + const unannotatedCssClass = hotSpot.cssClass.replace("isaac-annotated", ""); + const annotateClass = "isaac-annotated"; + let cssClass = unannotatedCssClass; + if (isAnnotated) { + cssClass += " " + annotateClass; + } + hotSpot.cssClass = cssClass; +} + +function isaacIsHotSpotVisible(hotSpot) { + const viewConfig = window.hotSpotViewConfig; + if (hotSpot.type == "scene") { + return viewConfig.showSceneLinks; + } else if (hotSpot.type == "info") { + return viewConfig.showSourceImageLinks; + } else { + console.log("ERROR: Unknown hotspot type " + hotSpot.type); + return false; + } +} + +function isaacRefreshHotSpots() { var config = window.viewer.getConfig(); - var currentSceneId = window.viewer.getScene(); + var storageItem = isaacStorageGetRoot(); - // Update hotSpots for all scenes. That way the visibility change - // will persist when the scene changes. for (let [sceneId, scene] of Object.entries(config.scenes)) { - // back up original complete hotSpots array if needed - if (!scene.hasOwnProperty('initialHotSpots')) { - scene.initialHotSpots = [...scene.hotSpots]; + // Remove all hotspots in the current Pannellum view. Note: We + // have to copy the current hotspots first to avoid iterating + // through an array while modifying it. + const currentHotSpots = [...scene.hotSpots]; + for (let hotSpot of currentHotSpots) { + window.viewer.removeHotSpot(hotSpot.id, sceneId); } - if (visibility) { - // add hotSpots matching type - for (const hotSpot of scene.initialHotSpots) { - if (hotSpot.type == hotSpotType) { - // console.log("window.viewer.addHotSpot(" + hotSpot.id + "," + sceneId + ");"); - window.viewer.addHotSpot(hotSpot, sceneId); - } - } - } else { - // remove hotSpots matching type - var hotSpotsCopy = [...scene.hotSpots]; - for (const hotSpot of hotSpotsCopy) { - if (hotSpot.type == hotSpotType) { - // console.log("window.viewer.removeHotSpot(" + hotSpot.id + "," + sceneId + ");"); - window.viewer.removeHotSpot(hotSpot.id, sceneId); + // Add back hotspots that should be visible currently, + // adjusting styling if needed. + for (let hotSpot of scene.initialHotSpots) { + if (isaacIsHotSpotVisible(hotSpot)) { + if (hotSpot.type == "info") { + isaacSetAnnotatedStyle(hotSpot, isaacHasAnnotations(storageItem, sceneId, hotSpot)); } + window.viewer.addHotSpot(hotSpot, sceneId); } } } } function isaacShowSceneLinks(visibility) { - isaacShowHotSpotType("scene", visibility); + window.hotSpotViewConfig.showSceneLinks = visibility; + isaacRefreshHotSpots(); } function isaacShowSourceImageLinks(visibility) { - isaacShowHotSpotType("info", visibility); + window.hotSpotViewConfig.showSourceImageLinks = visibility; + isaacRefreshHotSpots(); } const ISAAC_CHANGE_VISIBILITY_HANDLERS = { @@ -217,6 +214,10 @@ function isaacToggleEntryCheckBox(event) { ISAAC_CHANGE_VISIBILITY_HANDLERS[elt.id](visibility); } +function isaacDeepCopy(obj) { + return JSON.parse(JSON.stringify(obj)); +} + function isaacInitViewDropDown() { document.getElementsByClassName("isaac-drop-button")[0].onclick = isaacToggleDropDown; @@ -225,6 +226,19 @@ function isaacInitViewDropDown() { entry.onclick = isaacToggleEntryCheckBox; } + // Back up the initial/complete list of hotspots for each scene, + // given we may need to remove and add them back later based on the + // user's selected view options. + var config = window.viewer.getConfig(); + for (let [sceneId, scene] of Object.entries(config.scenes)) { + scene.initialHotSpots = [...scene.hotSpots]; + } + + window.hotSpotViewConfig = { + showSceneLinks: true, + showSourceImageLinks: true + }; + // Close the dropdown if the user clicks outside of it window.onclick = async function(event) { if (!event.target.matches('.isaac-drop-button')) { @@ -239,4 +253,145 @@ function isaacInitViewDropDown() { } } -isaacInitViewDropDown(); +/* Return an array of [sceneId, imageid] for images that have annotations. */ +function isaacGetImagesWithTargets() { + var storageItem = isaacStorageGetRoot(); + var result = []; + for (const [sceneId, imageAnnotationsMap] of Object.entries(storageItem)) { + for (const [imageId, annotations] of Object.entries(imageAnnotationsMap)) { + if (annotations.length > 0) { + result.push([sceneId, imageId]); + } + }; + }; + return result; +} + +/* Process an annotation update that comes from the annotation storage + * object. This highlights source images that contain targets and enables + * the target selection Next/Previous buttons. + */ +function isaacPanoProcessStorageUpdate() { + isaacRefreshHotSpots(); +} + +/* Change the annotation review index by `delta`. Each review index + * refers to a sceneId, imageId pair. Changing the index switches to + * the scene and pans to the image. + * + * @param delta Specify delta = 1 for the Next button or -1 for the Previous button. + */ +function isaacPanoReviewUpdate(delta) { + const reviewImages = isaacGetImagesWithTargets(); + if (reviewImages.length == 0) return; + ISAAC_REVIEW_INDEX = (ISAAC_REVIEW_INDEX + delta + reviewImages.length) % reviewImages.length; + const [sceneId, imageId] = reviewImages[ISAAC_REVIEW_INDEX]; + const hotSpot = window.initialConfig.scenes[sceneId].hotSpots.find((hs) => (hs.id == imageId)); + if (hotSpot == undefined) { + console.log("isaacPanoReviewUpdate: invalid sceneId, imageId:", sceneId, imageId); + return; + } + const reviewHfov = 50.0; + if (window.viewer.getScene() != sceneId) { + window.viewer.loadScene(sceneId, hotSpot.pitch, hotSpot.yaw, reviewHfov); + } else { + window.viewer.lookAt(hotSpot.pitch, hotSpot.yaw, reviewHfov); + } +} + +/* Download the full pano image as a file called '_pano.jpg'. */ +function isaacDownloadPanoImage() { + const sceneId = window.viewer.getScene(); + const a = document.createElement('a'); + a.href = "scenes/" + sceneId + "/pano.jpg"; + a.download = sceneId + "_pano.jpg"; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); +} + +/********************************************************************** + * Main initialization + **********************************************************************/ + +function isaacPanoInit(event) { + var config = event.configFromURL; + + if (config.initialAnnotations == null) { + config.initialAnnotations = {}; + } + // Initialize default annotation content in local storage, making + // it available to source image tabs that don't load the config + // from tour.json, just in case they need it. (This seems + // unlikely.) + window.localStorage.setItem(ISAAC_DEFAULT_CONTENT_KEY, JSON.stringify(config.initialAnnotations)); + + // Initialize annotations to the default if none are present. + var storageItem = isaacStorageGetRoot(); + delete storageItem.source_window_id; // ignore this field if present + if (Object.keys(storageItem).length == 0) { + isaacStorageSetRoot(config.initialAnnotations); + } + + window.initialConfig = config; + + var uiContainer = document.getElementsByClassName('pnlm-ui')[0]; + + var overviewMap = document.createElement('div'); + overviewMap.className = 'pnlm-overview-map pnlm-controls pnlm-control'; + uiContainer.appendChild(overviewMap); + window.overviewMap = overviewMap; + + overviewMap.addEventListener('click', overviewMapClick); + overviewMap.addEventListener('mousemove', overviewMapMouseMove); + overviewMap.addEventListener('mouseleave', overviewMapMouseLeave); + + for (let [sceneName, scene] of Object.entries(config.scenes)) { + var mapMarker = document.createElement('div'); + mapMarker.className = 'pnlm-map-marker'; + var pos = scene.overviewMapPosition; + mapMarker.style.left = pos[0] + "px"; + mapMarker.style.top = pos[1] + "px"; + overviewMap.appendChild(mapMarker); + } + + var mapHighlight = document.createElement('div'); + mapHighlight.className = 'pnlm-map-highlight'; + overviewMap.appendChild(mapHighlight); + window.mapHighlight = mapHighlight; + + var mapCurrent = document.createElement('div'); + mapCurrent.className = 'pnlm-map-current'; + overviewMap.appendChild(mapCurrent); + window.mapCurrent = mapCurrent; + + updateMapCurrent(); + + // Pannellum's default HFOV bounds of [50, 120] don't allow the + // user to zoom in extremely close, which is sometimes useful. + window.viewer.setHfovBounds([5, 120]); + + window.viewer.on("scenechange", updateMapCurrent); + window.viewer.on("mouseup", updateYaw); + window.viewer.on("touchend", updateYaw); + window.viewer.on("animatefinished", updateYaw); + + isaacInitViewDropDown(); + isaacConfigureLoadSaveClear(isaacPanoProcessStorageUpdate); + + document.getElementById('isaac-pano-image').addEventListener('click', isaacDownloadPanoImage); + + document.getElementById('isaac-previous').addEventListener('click', event => isaacPanoReviewUpdate(-1)); + document.getElementById('isaac-next').addEventListener('click', event => isaacPanoReviewUpdate(1)); + + // Load annotations + isaacPanoProcessStorageUpdate(); + + // Handle annotation changes triggered by other windows + window.addEventListener( + "storage", + (event) => isaacPanoProcessStorageUpdate() + ); +} + +document.addEventListener('pannellumloaded', isaacPanoInit, false); diff --git a/pano/pano_view/static/js/isaac_source_image.js b/pano/pano_view/static/js/isaac_source_image.js index c1d24588..3c22ec50 100644 --- a/pano/pano_view/static/js/isaac_source_image.js +++ b/pano/pano_view/static/js/isaac_source_image.js @@ -19,6 +19,24 @@ /* View source image with OpenSeaDragon */ +const ISAAC_STORAGE_ROOT_KEY = 'isaac_anno'; +const ISAAC_DEFAULT_CONTENT_KEY = 'isaac_anno_default'; + +/* The index used to remember what annotation we're reviewing using + * the Next/Previous buttons. */ +var ISAAC_REVIEW_INDEX = 0; + +/* Simple should-be-unique ID for this window. Used to distinguish + * between local storage updates coming from this window vs. from + * other windows. The timestamp has millisecond precision. */ +const ISAAC_WINDOW_ID = Date.now(); + +/* Return a configuration dict parsed from the hash component of the + * window's location URL. Falls back to the query parameter component + * if the hash component is not set. The parsing is strict and will + * only accept fields 'scene' and 'imageId'. Example: URL + * 'https://some.url/#scene=x&imageId=y' -> {scene: 'x', imageId: + * 'y'}. */ function parseUrlParameters() { var url; if (window.location.hash.length > 0) { @@ -42,6 +60,7 @@ function parseUrlParameters() { switch(option) { case 'scene': case 'imageId': + case 'slug': configFromUrl[option] = decodeURIComponent(value); break; default: @@ -53,85 +72,113 @@ function parseUrlParameters() { return configFromUrl; } +/* Return obj[field] if set. Otherwise, set obj[field] = defaultValue + * and return defaultValue. */ function isaacSetDefault(obj, field, defaultValue) { if (obj[field] == undefined) { - obj[field] = defaultValue; - return defaultValue; + obj[field] = defaultValue; + return defaultValue; } return obj[field]; } +/* Return the application's storage object (parsed from a JSON string stored + * at the ISAAC_STORAGE_ROOT_KEY in HTML5 window.localStorage). */ function isaacStorageGetRoot() { - var storageItemText = window.localStorage.getItem('annotations') || "{}"; + var storageItemText = window.localStorage.getItem(ISAAC_STORAGE_ROOT_KEY); + if (storageItemText == null) { + storageItemText = window.localStorage.getItem(ISAAC_DEFAULT_CONTENT_KEY); + } + if (storageItemText == null) { + storageItemText = "{}"; + } return JSON.parse(storageItemText); } +/* Set the applications storage object to `obj`. */ function isaacStorageSetRoot(obj) { - window.localStorage.setItem('annotations', JSON.stringify(obj)); + obj.source_window_id = ISAAC_WINDOW_ID; + window.localStorage.setItem(ISAAC_STORAGE_ROOT_KEY, JSON.stringify(obj)); } +/* Return the result of drilling down into `obj`, treating `fieldPath` + * as an array of references to retrieve. Example: + * isaacGetFieldPath(obj, ["foo", 1]) -> obj.foo[1]. */ function isaacGetFieldPath(obj, fieldPath) { for (fieldElt of fieldPath) { - obj = obj[fieldElt]; - if (obj == undefined) { - return undefined; - } + obj = obj[fieldElt]; + if (obj == undefined) { + return undefined; + } } return obj; } +/* Return the storage object value at `fieldPath`, where `fieldPath` + * is an array of field names used to drill down into the storage + * object. */ function isaacStorageGet(fieldPath) { var storageItem = isaacStorageGetRoot(); return isaacGetFieldPath(storageItem, fieldPath); } +/* Companion to `isaacStorageGet()`. Set the storage object value at + * `fieldPath` to `value`. */ function isaacStorageSet(fieldPath, value) { if (fieldPath.length == 0) { - isaacStorageSetRoot(value); - return; + isaacStorageSetRoot(value); + return; } var storageItem = isaacStorageGetRoot(); var obj = storageItem; for (fieldElt of fieldPath.slice(0, -1)) { - obj = isaacSetDefault(obj, fieldElt, {}); + obj = isaacSetDefault(obj, fieldElt, {}); } obj[fieldPath[fieldPath.length - 1]] = value; isaacStorageSetRoot(storageItem); } +/* Return the storage object value at `fieldPath`. If not set, set it + * to defaultValue and return defaultValue. + */ function isaacStorageSetDefault(fieldPath, defaultValue) { var storageItem = isaacStorageGetRoot(); var obj = storageItem; for (fieldElt of fieldPath.slice(0, -1)) { - obj = isaacSetDefault(obj, fieldElt, {}); + obj = isaacSetDefault(obj, fieldElt, {}); } var fieldLast = fieldPath[fieldPath.length - 1]; if (obj[fieldLast] == undefined) { - obj[fieldLast] = defaultValue; - isaacStorageSetRoot(storageItem); - return defaultValue; + obj[fieldLast] = defaultValue; + isaacStorageSetRoot(storageItem); + return defaultValue; } return obj[fieldLast]; } +/* Delete the storage object value at `fieldPath`. */ function isaacStorageDelete(fieldPath) { var storageItem = isaacStorageGetRoot(); var obj = storageItem; for (fieldElt of fieldPath.slice(0, -1)) { - obj = isaacSetDefault(obj, fieldElt, {}); + obj = isaacSetDefault(obj, fieldElt, {}); } delete obj[fieldPath[fieldPath.length - 1]]; isaacStorageSetRoot(storageItem); } +/* Convert `data` to a JSON string and trigger the browser to download + * it as an attachment named `fileName`. Note: This has a side-effect + * of deleting the `source_window_id` field of `data`, which we don't want to save. */ function isaacSaveData(data, fileName) { + delete data.source_window_id; var json = JSON.stringify(data, null, 4); var blob = new Blob([json], {type: 'application/json'}); var url = window.URL.createObjectURL(blob); @@ -148,23 +195,29 @@ function isaacSaveData(data, fileName) { a.remove(); } +/* The maximum depth of the history undo and redo stacks. */ var ISAAC_HISTORY_MAX_LENGTH = 10; +/* Set the disabled status of the Undo and Redo buttons based on the + * annotation history stacks. */ function isaacHistoryUpdateEnabled(history) { document.getElementById('isaac-undo').disabled = (history.undoStack.length == 0); document.getElementById('isaac-redo').disabled = (history.redoStack.length == 0); } +/* Log a debug message about the annotation history stacks. */ function isaacHistoryDebug(history) { console.log("#undo=" + history.undoStack.length + " #redo=" + history.redoStack.length) } +/* Save a new annotation state to the history and update the disabled + * state of the Undo/Redo buttons. */ function isaacHistorySaveState(history, state) { if (history.current != null) { - history.undoStack.push(history.current); + history.undoStack.push(history.current); } if (history.undoStack.length > ISAAC_HISTORY_MAX_LENGTH) { - history.undoStack.shift(); + history.undoStack.shift(); } history.current = state; history.redoStack = []; @@ -173,16 +226,32 @@ function isaacHistorySaveState(history, state) { isaacHistoryDebug(history); } +/* Update the Annotorious part of the UI to reflect a new annotation + * state. This doesn't modify the storage object or update the + * history. + * + * @param history A history object with fields 'current', 'undoStack', 'redoStack'. + * @param state The annotation state (corresponds to the complete storage object). + * @param imageStoragePath A path within `state` referencing the annotation state for the current image. + * @param anno A reference to the live instance of the Annotorious viewer. + */ function isaacRenderState(history, state, imageStoragePath, anno) { var imageAnnotations = isaacGetFieldPath(state, imageStoragePath) || []; anno.setAnnotations(imageAnnotations); isaacHistoryUpdateEnabled(history); } +/* Perform an undo operation. This updates the current annotation state, adjusts the history undo/redo stacks, + * saves the annotation state to the storage object, and updates the UI. + * + * @param history A history object with fields 'current', 'undoStack', 'redoStack'. + * @param imageStoragePath A path within the storage object referencing the annotation state for the current image. + * @param anno A reference to the live instance of the Annotorious viewer. + */ function isaacHistoryUndo(history, imageStoragePath, anno) { if (history.undoStack.length == 0) { - console.log('got undo request with no undo stack, should never happen'); - return; + console.log('got undo request with no undo stack, should never happen'); + return; } history.redoStack.push(history.current); history.current = history.undoStack.pop(); @@ -193,10 +262,11 @@ function isaacHistoryUndo(history, imageStoragePath, anno) { isaacHistoryDebug(history); } +/* Perform a redo operation. Params and effects similar to undo (see above). */ function isaacHistoryRedo(history, imageStoragePath, anno) { if (history.redoStack.length == 0) { - console.log('got redo request with no redo stack, should never happen'); - return; + console.log('got redo request with no redo stack, should never happen'); + return; } history.undoStack.push(history.current); history.current = history.redoStack.pop(); @@ -207,44 +277,144 @@ function isaacHistoryRedo(history, imageStoragePath, anno) { isaacHistoryDebug(history); } +/* Record an Annotorious edit operation. This updates the storage object and the + history undo/redo stacks, and updates the non-Annotorious part of the UI. + (The Annotorious part of the UI is assumed to be the source of the edit and + shouldn't need to be updated.) */ function isaacUpdateAnnotations(fieldPath, value, history) { isaacStorageSet(fieldPath, value); isaacHistorySaveState(history, isaacStorageGetRoot()); } +/* Return a Promise that resolves to the text content of `file` (a + local file picked by the user for upload using the "Load" + button). */ function isaacReadAsText(file) { // Always return a Promise return new Promise((resolve, reject) => { - let content = ''; - const reader = new FileReader(); - // Wait till complete - reader.onloadend = function(e) { - resolve(e.target.result); - }; - // Make sure to handle error states - reader.onerror = function(e) { - reject(e); - }; - reader.readAsText(file); + let content = ''; + const reader = new FileReader(); + // Wait till complete + reader.onloadend = function(e) { + resolve(e.target.result); + }; + // Make sure to handle error states + reader.onerror = function(e) { + reject(e); + }; + reader.readAsText(file); }); } +/* Change the annotation review index by `delta`. This changes which + * annotation is highlighted and pans to the highlighted annotation. + * + * @param imageStoragePath A path within the storage object referencing the annotation state for the current image. + * @param anno A reference to the live instance of the Annotorious viewer. + * @param delta Specify delta = 1 for the Next button or -1 for the Previous button. + */ +function isaacReviewUpdater(imageStoragePath, anno, delta) { + var annotations = isaacStorageGet(imageStoragePath); + if (!annotations) return; + ISAAC_REVIEW_INDEX = (ISAAC_REVIEW_INDEX + delta + annotations.length) % annotations.length; + var annoId = annotations[ISAAC_REVIEW_INDEX].id; + anno.selectAnnotation(annoId); + anno.panTo(annoId); +} + +/* Process an annotation update that comes from the storage + * object. This updates the history and both the Annotorious and + * non-Annotorious parts of the UI. + * + * @param history A history object with fields 'current', 'undoStack', 'redoStack'. + * @param imageStoragePath A path within the storage object referencing the annotation state for the current image. + * @param anno The live instance of the Annotorious viewer. + */ +function isaacProcessStorageUpdate(history, imageStoragePath, anno) { + var storageItem = isaacStorageGetRoot(); + isaacRenderState(history, storageItem, imageStoragePath, anno); + isaacHistorySaveState(history, storageItem); +} + +/* Handle an annotation update that comes from the storage + * object. This updates the history and both the Annotorious and + * non-Annotorious parts of the UI. + * + * @param history A history object with fields 'current', 'undoStack', 'redoStack'. + * @param imageStoragePath A path within the storage object referencing the annotation state for the current image. + * @param anno The live instance of the Annotorious viewer. + */ +function isaacHandleStorageEvent(history, imageStoragePath, anno, event) { + if (event.key !== null && event.key != ISAAC_STORAGE_ROOT_KEY) { + // Ignore storage events unless they affect our key. The event key will be null + // for a clear event that affects all keys. + return; + } + var storageItem = JSON.parse(event.newValue); + if (storageItem.source_window_id == ISAAC_WINDOW_ID) { + // Ignore storage events that were triggered by changes made in this + // window -- they will already have been explicitly handled, or not, + // as needed. + return; + } + isaacProcessStorageUpdate(history, imageStoragePath, anno); +} + +/* Configure handlers for the Load, Save, and Clear buttons. + * + * @param storageUpdateHandler A callback invoked with no arguments when the storage object changes. + */ +function isaacConfigureLoadSaveClear(storageUpdateHandler) { + var isaacLoadInput = document.getElementById('isaac-load-input'); + document.getElementById('isaac-load').addEventListener('click', function(event) { + isaacLoadInput.click(); + }); + isaacLoadInput.addEventListener('change', async function(event) { + console.log('load change event'); + console.log(isaacLoadInput); + if (isaacLoadInput.files.length > 0) { + var loadFile = isaacLoadInput.files[0]; + var loadText = await isaacReadAsText(loadFile); + var storageItem = JSON.parse(loadText); + isaacStorageSetRoot(storageItem); + storageUpdateHandler(); + } + }); + + document.getElementById('isaac-save').addEventListener('click', function(event) { + isaacSaveData(isaacStorageGetRoot(), 'isaac_iss_annotations.json'); + }); + document.getElementById('isaac-clear').addEventListener('click', function(event) { + isaacStorageSetRoot({}); + storageUpdateHandler(); + }); +} + +/* Perform overall initialization for the ISAAC pano tour source image + viewer. */ function initIsaacSourceImage() { var configFromUrl = parseUrlParameters(); // Initialize OpenSeaDragon viewer and Annotorious plugin var viewer = OpenSeadragon({ id: 'container', - prefixUrl: '../../media/openseadragon/', - tileSources: '../../source_images/' + configFromUrl['scene'] + '/' - + configFromUrl['imageId'] + '.dzi' + prefixUrl: '../media/openseadragon/', + tileSources: '../source_images/' + configFromUrl['scene'] + '/' + + configFromUrl['imageId'] + '.dzi', + maxZoomPixelRatio: 5 }); - var anno = OpenSeadragon.Annotorious(viewer); + const annoConfig = { + allowEmpty: true + }; + var anno = OpenSeadragon.Annotorious(viewer, annoConfig); + // anno.setDrawingEnabled(true); var history = { - 'current': null, - 'undoStack': [], - 'redoStack': [] + 'current': null, + 'undoStack': [], + 'redoStack': [] }; + // anno.removeDrawingTool('rect'); + // anno.removeDrawingTool('polygon'); // Export symbols for debugging window.configFromUrl = configFromUrl; @@ -254,77 +424,55 @@ function initIsaacSourceImage() { // Configure extra point drawing tool (default tools are rect and polygon only) Annotorious.SelectorPack(anno, { - tools: ['point'] + tools: ['point'] }); - // Create toolbar - Annotorious.Toolbar(anno, document.getElementById('isaac-toolbar-container')); + // Set slug text + slugText = configFromUrl['slug'].replaceAll("_", " "); + document.getElementById('isaac-slug').textContent = slugText; + document.title = slugText + ": ISAAC ISS Tour"; // Configure other button handlers + document.getElementById('isaac-raw-anchor').href = '../../source_images/' + + configFromUrl['scene'] + '/' + + configFromUrl['imageId'] + '.jpg'; var imageStoragePath = [configFromUrl.scene, configFromUrl.imageId]; document.getElementById('isaac-undo').addEventListener( - "click", - event => isaacHistoryUndo(history, imageStoragePath, anno) + 'click', + event => isaacHistoryUndo(history, imageStoragePath, anno) ); document.getElementById('isaac-redo').addEventListener( - "click", - event => isaacHistoryRedo(history, imageStoragePath, anno) + 'click', + event => isaacHistoryRedo(history, imageStoragePath, anno) ); - var isaacLoadInput = document.getElementById('isaac-load-input'); - document.getElementById('isaac-load').addEventListener('click', function(event) { - isaacLoadInput.click(); - }); - isaacLoadInput.addEventListener('change', async function(event) { - console.log('load change event'); - console.log(isaacLoadInput); - if (isaacLoadInput.files.length > 0) { - var loadFile = isaacLoadInput.files[0]; - var loadText = await isaacReadAsText(loadFile); - var storageItem = JSON.parse(loadText); - isaacStorageSetRoot(storageItem); - isaacHistorySaveState(history, storageItem); - isaacRenderState(history, storageItem, imageStoragePath, anno); - } - }); + document.getElementById('isaac-add').addEventListener( + 'click', + event => { + anno.setDrawingTool('rect'); + anno.setDrawingEnabled(true); + } + ); - document.getElementById('isaac-save').addEventListener('click', function(event) { - isaacSaveData(isaacStorageGetRoot(), 'annotations.json'); - }); - document.getElementById('isaac-clear').addEventListener('click', function(event) { - isaacRenderState(history, {}, imageStoragePath, anno); - isaacUpdateAnnotations([], {}, history); - }); + var storageUpdateHandler = () => isaacProcessStorageUpdate(history, imageStoragePath, anno) + isaacConfigureLoadSaveClear(storageUpdateHandler); - var reviewIndex = 0; - var reviewUpdater = function(delta) { - return function(event) { - var annotations = isaacStorageGet(imageStoragePath); - if (!annotations) return; - reviewIndex = (reviewIndex + delta + annotations.length) % annotations.length; - var annoId = annotations[reviewIndex].id; - anno.selectAnnotation(annoId); - anno.panTo(annoId); - } - } + var reviewUpdater = delta => (event => isaacReviewUpdater(imageStoragePath, anno, delta)); document.getElementById('isaac-previous').addEventListener('click', reviewUpdater(-1)); document.getElementById('isaac-next').addEventListener('click', reviewUpdater(1)) // Restore annotations on this image from HTML5 local storage - var storageItem = isaacStorageGetRoot(); - isaacRenderState(history, storageItem, imageStoragePath, anno); - isaacHistorySaveState(history, storageItem); + isaacProcessStorageUpdate(history, imageStoragePath, anno); + + // Handle storage changes triggered by other windows + window.addEventListener( + "storage", + (event) => isaacHandleStorageEvent(history, imageStoragePath, anno, event) + ); // Save subsequent drawing events to HTML5 local storage - anno.on('createAnnotation', function(annotation) { - isaacUpdateAnnotations(imageStoragePath, anno.getAnnotations(), history); - }); - anno.on('updateAnnotation', function(annotation) { - isaacUpdateAnnotations(imageStoragePath, anno.getAnnotations(), history); - }); - anno.on('deleteAnnotation', function(annotation) { - isaacUpdateAnnotations(imageStoragePath, anno.getAnnotations(), history); - }); + var annoEventHandler = annotation => isaacUpdateAnnotations(imageStoragePath, anno.getAnnotations(), history); + anno.on('createAnnotation', annoEventHandler); + anno.on('updateAnnotation', annoEventHandler); + anno.on('deleteAnnotation', annoEventHandler); } - -initIsaacSourceImage(); diff --git a/pano/pano_view/templates/help.html b/pano/pano_view/templates/help.html new file mode 100644 index 00000000..bed09ebc --- /dev/null +++ b/pano/pano_view/templates/help.html @@ -0,0 +1,112 @@ + + + + + + Help: ISAAC ISS Tour + + + +

    ISAAC ISS Tour Help

    +

    Panoramic Tour Viewer

    +

    The Panoramic Tour Viewer lets you freely explore the interior of the ISS. Some features include:

    + + +

    Image Viewer

    +

    The Image Viewer lets you view and annotate an individual image collected by the Astrobee robots. Some features include:

    + + + + diff --git a/pano/pano_view/templates/index.html b/pano/pano_view/templates/index.html index f3eb9416..69a62b90 100644 --- a/pano/pano_view/templates/index.html +++ b/pano/pano_view/templates/index.html @@ -6,7 +6,7 @@

    ISAAC Astrobee Panoramas

    -

    [Start exploring here]

    +

    [Start exploring here]

    Or jump straight to a specific bay:

    diff --git a/pano/pano_view/templates/isaac_source_image.html b/pano/pano_view/templates/isaac_source_image.html index 024e7a8a..44bb9e79 100644 --- a/pano/pano_view/templates/isaac_source_image.html +++ b/pano/pano_view/templates/isaac_source_image.html @@ -3,13 +3,23 @@ - ISAAC Source Image - + ISAAC ISS Tour + + + + + + + + + +
    + ISAAC ISS Tour: + + + + + Annotations: -
    + Load +